Compare commits

...
Author SHA1 Message Date
Daniel Hiltgen d67ad83426 mlx update (#17761) 2026-08-15 11:56:40 -07:00
Daniel Hiltgen e5a81899d0 llama.cpp update (#17760) 2026-08-14 19:03:20 -07:00
Parth Sareen 78e818e3ce docs: register DeepSeek Harness (#17751) 2026-08-14 14:30:21 -07:00
Daniel Hiltgen 87abaa019e renderers/qwen: tolerate non-leading system messages (#17757)
Coding clients may insert runtime system messages after the initial user turn. The shared Qwen renderer rejected these transcripts before rendering, turning a potentially usable non-standard request into an HTTP 500.

Pass non-leading system turns through the existing raw ChatML path and warn when qwen3.8 encounters one. Extend the Anthropic tool-route integration scenario to cover this message pattern and remove the obsolete rejection test.
2026-08-14 14:12:50 -07:00
Daniel Hiltgen f427fa0753 llm: transcode WebP images for llama-server (#17755)
llama-server does not currently support WebP image payloads. Detect WebP media before forwarding, and transcode it to PNG. Pass all other media through unchanged.

Replace an existing vision integration image with a lossless WebP version so we now have coverage of JPG/PNG/WebP formats.

Fixes #17753
2026-08-14 13:21:11 -07:00
Daniel Hiltgen 0f25c31bd5 qwen3.8: support developer instructions (#17749)
* qwen3.8: support developer instructions

Qwen3.8 does not define a developer role, while OpenAI-compatible coding agents commonly send developer instructions before user messages. Fold the leading system/developer instruction prefix into a single system turn before Qwen3.8 validation, preserving instruction precedence without changing Qwen3.5 or other renderer behavior.

Add streaming tool-call integration coverage for the native Ollama, OpenAI Chat Completions, OpenAI Responses, and Anthropic Messages request shapes. Each case exercises prior assistant tool calls, tool results, follow-up rendering, and parsed tool-call output. Add Qwen3.8 to the release tools sweep.

Removes an unnecessary unit test that should not have been included in the original 3.8 PR.

* review comments
2026-08-14 11:30:27 -07:00
Daniel Hiltgen 5512797527 qwen3.8: add renderer and MLX import support (#17745)
Qwen3.8 keeps the Qwen3.5 model architecture and parser, but its chat template adds reasoning-effort and preserved-thinking semantics. Detect those template markers during safetensors import, select the qwen3.8 renderer, and cover thinking, tools, continuation, and malformed parser input.

Make indexed safetensors imports use the weight map's shard names instead of independently filtering files by the model-* convention. Reject unsafe shard paths, ignore unindexed tensors, and fail when an indexed weight is missing or stored in a different shard. Retain the conservative model-* scan when no index is present.

Treat Classification.Quantize as the effective tensor format and pass it to the manifest writer. This records file_type for automatic block-FP8-to-MXFP8 conversion and recognized prequantized inputs, preserves requested quantization and base-plus-draft behavior, and avoids claiming one type for mixed or unknown formats.

Normalize both supported convolution weight layouts with an explicit reshape. Add focused unit coverage for renderer selection, parser behavior, shard inventory, manifest metadata, and convolution layout; heavyweight reference-forward and release integration checks remain bring-up artifacts.
2026-08-14 09:31:09 -07:00
Parth Sareen 39df91c982 launch: add DeepSeek Harness integration (#17733) 2026-08-13 15:19:02 -07:00
Daniel Hiltgen 7ce88bd686 model/renderers: match Muse Glimmer reasoning template (#17732)
Updates Muse Glimmer Jinja reference template to the latest publisher version and mirror its explicit-system reasoning handling in the Go renderer.

Explicit system prompts now normalize "Reasoning effort" to "Reasoning strength" and skip adding a renderer-provided reasoning line when the prompt already contains one. This prevents duplicate or conflicting reasoning directives while preserving the default-system behavior.

Add reference tests for both normalization and deduplication, including Jinja-backed validation.
2026-08-13 14:35:50 -07:00
Daniel Hiltgen 01d04d50f8 launch: add Muse Code integration (#17594)
* launch: add Muse Code integration

Add `ollama launch muse` for Meta's Muse Code CLI.

Muse only takes a model catalog from settings.json (normally it fetches one from its provider and refuses to start otherwise), and that file's endpoint_transport is a global provider switch. So the integration writes a settings file under its own config root (~/.ollama/launch/muse-config via XDG_CONFIG_HOME), leaving a Meta-backed muse install untouched, and re-seeds it from muse's own persisted copy on later runs.

The launched model is preloaded so its catalog row carries the context length the server actually allocated, not the trained maximum; the loaded-context helpers move from cmd/agent_tui.go into cmd/launch for reuse.

Muse sends reasoning efforts outside Ollama's scale (minimal, xhigh, ultra), which were hard 400s; clamp them to the nearest tier in one helper shared by the chat and responses converters.

The registry entry stays Hidden (alias "muse-code"), like kimi and vscode.

* review comments

* skip muse test on windows (unsupported platform)
2026-08-13 13:10:29 -07:00
Parth Sareen 9a56a0e845 agent: allow multiple edits per edit tool call (#17711) 2026-08-12 16:45:53 -07:00
Daniel Hiltgen 88313499e0 mlx: avoid pulling MLX models when MLX is missing (#17710)
As we look to bring Linux and Windows MLX support online, instead of blocking
downloads at the registry to avoid users wasting time downloading a model they
can't run, shift the logic to the local side which knows if MLX is present or not.
2026-08-12 14:42:17 -07:00
Jesse Gross 2b4a99376c nn: speed up prefill on double-scale nvfp4 models
ModelOpt checkpoints apply a float32 global scale to every projection
output on top of the per-group quantization scales. Running the
multiply and the cast back to the activation dtype as separate eager
ops costs an extra kernel launch and a materialized intermediate per
projection.

Compile the multiply and cast into one kernel. On an M5 Max (medians
of order-swapped A/B runs against main; greedy outputs byte-identical):

    qwen3.6:27b        prefill  703 -> 769 t/s  +7.9%
    muse-glimmer:30b   prefill  790 -> 843 t/s  +6.7%

Speculative decode is unchanged within noise on both models. Only
checkpoints with a global scale are affected; single-scale nvfp4,
mxfp8, and affine checkpoints take the unchanged path.
2026-08-12 13:25:33 -07:00
Daniel Hiltgen e922bc7125 llama.cpp bump (#17702) 2026-08-12 12:10:18 -07:00
Daniel Hiltgen 950dd9ac67 MLX update (#17704) 2026-08-12 12:09:50 -07:00
Parth Sareen b6b1b258c3 openai: support web search in Responses API (#17686) 2026-08-12 11:51:54 -07:00
VigneshandPatrick Devine 4138e853d5 server/images: prevent skipVerify map collision with duplicate digests (#15504)
When a manifest contains a config and layer with the same digest, the
skipVerify map entry was overwritten by the config's cache-hit value
(true), replacing the layer's non-cache-hit value (false). This caused
verifyBlob to be skipped for the freshly downloaded blob.

A rogue OCI registry could exploit this by serving a manifest with
duplicate digests and redirecting blob downloads to internal endpoints.
The SSRF response would be written to disk, hash verification would be
skipped due to the map collision, and the blob would persist.

The fix uses logical AND when updating skipVerify: once any download of
a digest was not a cache hit, verification is always performed.

Fixes #15485

---------

Co-authored-by: Patrick Devine <patrick@ollama.com>
2026-08-12 11:44:32 -07:00
Daniel Hiltgen 641df5e5ad mlx: enable CUDA backend in CUDA builds (#17688) 2026-08-12 07:31:00 -07:00
Jesse Gross 6a261db7d8 api: stop applying repeat_penalty 1.1 to models that don't set one
Request options are the model's published parameters and the request's
own options layered over the server defaults, so the default
repeat_penalty of 1.1 reaches every model whose parameters leave it
unset. No maker of the library's current models recommends 1.1: their
generation configs either omit the penalty, meaning 1.0, or pin 1.05.
llama.cpp dropped the same 1.1 default in 2024; vLLM, SGLang, and
transformers apply no penalty. An always-on penalty also distorts
output that legitimately repeats tokens, such as code, JSON, and long
reasoning traces.

The penalty is especially costly for speculative decoding, where
drafts are proposed without it: the penalized target rejects drafted
tokens and the depth controller backs off. On muse-glimmer 30B (DFlash
on M5 Max, HumanEval) the 1.1 default costs 13-16% of end-to-end
throughput at greedy and temperature 1 alike, and drops prose
acceptance at temperature 0.8 from 0.44 to 0.30. On qwen3.6-35B it
cuts the mean accepted draft length from 4.3 to 3.5 tokens and makes
the controller stop speculating on prose.

Defaulting to 1.0 disables the penalty unless a model's parameters or
the request set one. Across the library:

- qwen3, qwen3.6, and qwen3-coder pin their own values (1.0, 1.0, and
  Qwen's recommended 1.05) and are unchanged.
- Everything else local now matches its maker's no-penalty
  recommendation, including gemma2 through gemma4, muse-glimmer, both
  laguna 2.1 models, qwen3.5 (previously 1.1 stacked on its
  presence_penalty of 1.5), gpt-oss, deepseek-r1 and v3.1, the
  nemotron family, granite4, the mistral and llama3/llama4 families,
  phi4, glm4, llava, and devstral.
- qwen2.5 recommends 1.05 but ships no parameters, so it moves from
  1.1 to 1.0 and still needs a parameters layer to conform.
- Cloud models (kimi-k3, deepseek-v4-flash) never receive these
  defaults.

Small older models may repeat themselves more without the penalty
masking it; the remedy is a per-model parameter, not a penalty applied
to every model.
2026-08-11 21:47:51 -07:00
Eva H 948f69330a docs: fix broken links (#17676) 2026-08-11 14:36:39 -07:00
Daniel Hiltgen 96fb6d2fa9 nemotron_h: support the Nemotron 3.5 prompt layout (#17672)
Select the 3.5 parser and renderer from its checkpoint template, preserve its prompt semantics, and map medium reasoning effort to the final-user annotation expected by the reference template.

Exercise parser and renderer registration, create-time metadata inference, and exact Jinja parity so created models cannot silently fall back to the Nemotron 3 renderer.
2026-08-11 06:18:51 -07:00
Daniel Hiltgen 400164d47c parsers: recover boundary tokens fumbled into glimmer ATEM invoke names (#17664)
The model occasionally emits a <|message|> boundary token in the invoke
name region, echoing the header form `to=read<|message|>`. The existing
recovery handled the tag inside a terminated name (`name="read<|message|>">`)
but not the fleet-observed shape where the tag replaces the `">` terminator
itself (`name="read<|message|><atem:parameter ...`), which failed the call
with "malformed ATEM parameter".

Replace the strip-after-cut recovery with a single name scan shared by
parseGlimmerATEM and the content fallback: the name ends at the first `">`,
boundary tokens before it are dropped, and a parameter element immediately
after a dropped token means the token replaced the terminator. Well-formed
calls are unaffected — a boundary token is never legitimate before the
terminator, and parameter values (where the literal text is preserved) only
appear after it. Murkier garbles still fail loudly, the recipient
cross-check still applies, and the recovery WARN is retained.
2026-08-10 21:48:17 -07:00
Daniel Hiltgen bb7bba885e mlx: implement Nemotron 3 Nano Omni (#17060)
Add MLX support for Nemotron 3 Nano Omni, including the model implementation, Mamba2/recurrent pieces, MoE routing, and quantized NVFP4/MXFP8 expert paths.

Use a shared mapped MoE GatherQMM fast path under the generic moe_gather_qmm_mapped naming, with Metal-optimized NVFP4/MXFP8 block-mapped kernels and generic fallbacks for unsupported backends.

Serve the model's multi-token prediction head as a self-draft speculator, so speculative decoding needs no separate draft model.

Render the Nemotron prompt from the published chat template. The template the renderer was based on had drifted from the current reference; refreshing it surfaced five mismatches: stray leading newlines, the wrong turn separator and a trailing newline before the generation prompt; /think and /no_think toggles left in user turns; a trimmed system message the template leaves intact; a user block opened by a leading tool message; and Go scalar syntax for schema extras where the template applies Python str(), sending true/false/<nil> in place of True/False/None. Reference tests now render every case through the template itself.

Also harden the Nemotron parser path shared by both backends: while collecting thinking, preserve whitespace before partial </think>, <think>, and <tool_call> fakeouts, with streaming tests covering those cases.
2026-08-10 21:42:34 -07:00
Daniel Hiltgen 4f066a6fb0 llama.cpp update (#17659) 2026-08-10 15:48:32 -07:00
Eva H a836eb8c3c docs: require VS Code 1.127 (#17655) 2026-08-10 11:21:26 -07:00
Eva H 1a9e4235ac docs: add VS Code context length guidance (#17610) 2026-08-10 09:46:25 -07:00
Daniel Hiltgen 43f4eda808 Release v0.32.7 (#17646)
* glimmer: implement the Muse Glimmer model

MLX model (language + vision encoder) with DFlash draft wiring, llama-server DFlash support and rope-interleave fix, renderer and parser, tokenizer fixes, and the import quantization policy.

* mlxrunner: report committed prefill chunks after the sweep and eval

The drafter's flush evaluates its report, and an eval that runs while the chunk's construction handles are still live cannot free any intermediate buffer. On media chunks that retention keeps the whole vision tower resident and grinds the Metal allocator at its limit until the request dies. Pin the report's inputs across the sweep, report after the chunk materializes, and release media items after the report so a drafter can still capture the rows its deferred flush embeds.

* ci: retry CUDA pre-release download
2026-08-10 04:04:56 -07:00
Daniel Hiltgen acdf81510d MLX: version bump (#17637)
Also bring back version tagging the MLX library with our git hash which was
accidentally dropped when imagegen was removed.  Without this, the version
claimed to be the official tagged version, but we're typically using a git hash
with different content.
2026-08-09 10:38:49 -07:00
Jesse Gross 1e85fe8e9a qwen3_5: image input support
One vision path serves every qwen3.5/qwen3.6 registration, dense and
MoE. Rope positions are precomputed at prepare time as the request's
layout — the family uses interleaved M-RoPE — while text-only requests
keep the fused 1D rope path, which is numerically identical for
uniform channels. Image expansions are causal for this family, so
prefill chunks split them. The MTP head embeds prompt tokens, so it
scatters the delivered image features and applies the same position
tables, keeping speculative decoding working on image prompts. The
merger's exact erf GELU adds an Erf op to the MLX bindings.

A checkpoint whose config declares vision must ship its tower: missing
vision weights or a deepstack_visual_indexes request fail the load
rather than silently serving text-only or skipping the injections.
Text-only checkpoints, which carry no vision_config, load as before.

Verified tensor-by-tensor against HF transformers for all eight family
members and live on every published -mlx tag; published towers are
already bf16, so no re-import is needed.
2026-08-09 10:37:05 -07:00
Jesse Gross 5fcf71b8b8 mlxrunner: feed media features to the model during prefill
Each media item's features are encoded lazily when a prefill chunk
first overlaps its expansion and stay pinned until the expansion is
fully evaluated. A chunk never ends strictly inside an atomic
expansion: a bidirectional run's early rows attend its later keys, so
its first evaluation must cover the whole run in one forward. Items
marked Causal are exempt and split at any boundary.

Draft models need the same request state — reference MTP drafters
embed prompt tokens with the image features merged in, and an M-RoPE
drafter cannot compute positions without the request's layout — so the
layout is stamped on every forward, target and draft alike, and the
MTP session holds feature rows across its deferred flush. The dflash
drafter ignores media: its context rows are target hiddens.
2026-08-09 10:37:05 -07:00
Jesse Gross 60bdc23467 mlxrunner: expand image tags into placeholder tokens
A prompt that references media arrives as text containing [img-N] tags
plus the media bytes. Prepare now splits on the tags, tokenizes the text
between them, and hands the model the resulting segments — text runs and
media in stream order — in a single PrepareMedia call. The model returns
the expanded stream with each media segment's placeholder expansion
spliced in place, described per item so the runner can key identity and
schedule encoding, along with any opaque request-scoped layout state it
derives while building the stream. Building the whole stream in one call
is what lets a model derive values that span items, and lets it choose
item granularity (one per image, or one per independently evaluable
tile).

The runner validates the model-authored items before trusting them —
ranges ordered, non-overlapping, in bounds, and covering every media
segment, since prefix-cache identity is keyed on them.

Unknown tag IDs fail the request, media the prompt never references is
ignored with a warning, and duplicate references are allowed, matching
the previous engine. A media request still produces no image output:
nothing feeds the features to the model yet, and no model implements
the media interface.
2026-08-09 10:37:05 -07:00
Jesse Gross 694487c65b mlxrunner: fold media identity into prefix-trie keys
Media placeholders repeat one token ID, so two prompts with different
images would produce identical trie keys and falsely share cached state.
Substitute a per-item hash of the media bytes and preprocessing shape
across each item's expansion range at the key layer; the model still
sees real token IDs. Fold values carry a bit no token ID has, so a media
stream can never alias text, and the bigram packing for draft caches
composes unchanged, so draft restore points inherit the same identity.

Text-only prompts key exactly as before. Nothing records media items yet;
the change is inert until the prompt preparation wires them.
2026-08-09 10:37:05 -07:00
Jesse Gross af5b627672 mlxrunner: reject media requests the model cannot serve
MLX checkpoints that include a vision tower are already tagged with the
vision capability at import, so the server accepts image chats and ships
the image bytes with the completion request. The MLX client dropped the
bytes, and the prompt's image tags were answered as literal text.

Carry the media through to the runner and fail the request with a clear
error when the loaded model has no media support. Nothing implements the
new media interface yet, so every media request now returns the error
rather than a silently wrong answer; later changes build the image path
on top of the same interface.
2026-08-09 10:37:05 -07:00
Jesse Gross 8713570d3c create: keep vision towers at source precision when quantizing
Vision towers are much more sensitive to weight quantization than
language layers: measured against the reference encoder on a real
image, 4-bit types and scale-only mxfp8 distort the projected image
features by 26-34% mean relative error (worst tokens near-orthogonal),
which shows up as degraded image recognition — down to complete
blindness for the small e-series towers under nvfp4. Affine 8-bit was
the only quantized format that matched the bf16 tower.

Keep vision tower tensors at source precision instead, matching the
audio tower's treatment and every vision component Ollama publishes in
GGUF form, including gemma4's own GGUF tags, which ship f16/f32 vision
beside 4-bit language weights. Towers are small and run once per image,
so neither size nor decode bandwidth argues for quantizing. Existing
MLX imports keep their quantized towers until re-imported.
2026-08-09 10:37:05 -07:00
Daniel Hiltgen 5a173edb63 manifests: remove OCI rootfs from the model config (#17619)
rootfs.diff_ids duplicated the manifest's layer digest list into the config blob and nothing ever read it. On per-tensor safetensors models the copy grows past 100KB and create excessively large config blobs with unused redundant data. Model identity is unaffected: it is the digest of the manifest itself, which already commits to every layer hash.
2026-08-08 19:44:57 -07:00
Jesse Gross b880b76c43 laguna: wire the DFlash target side
Add what a DFlash draft borrows from its target: the tapped layer
outputs, the raw embedding lookup, and the undecorated lm_head
projection. The laguna draft architecture (DFlashLagunaForCausalLM) is
registered here, alongside the only wired target.

Matched nvfp4 target+draft pairs, M5 Max, temp 0.8, repeat_penalty 1.1,
adaptive depth; decode tok/s:

                     prose   code   edit
  laguna-xs  plain   139.4  139.7  137.3
             DFlash  142.3  139.2  145.1
  laguna-s   plain    75.4   70.0   72.4
             DFlash   74.6   80.8  115.3
2026-08-07 19:33:35 -07:00
Jesse Gross cf129bbb11 dflash: add the DFlash block-diffusion draft model
Implements the DFlash draft checkpoint format: a few decoder layers
over fused target-layer outputs, which enter every layer as key/value
context while the block being drafted supplies the queries. The draft
has no embedding table or output head of its own; it borrows the
target's.

One model covers the known checkpoints. Attention weights normalize at
load to a q projection plus a fused k|v, stacking split checkpoints and
slicing fused ones, exact for quantized tensors; gate and up fuse the
same way. Optional tensors decide the output gate and per-tap norms,
config decides attention shape, and the architecture name decides only
laguna's context-norm convention.

A manifest can pair any draft with any target, so construction checks
the fit: tap ids inside the target's layers, matching hidden width, and
the target vocabulary covering the mask token. A bad pairing fails at
load.
2026-08-07 19:33:35 -07:00
Jesse Gross c1bf60d7b1 mlxrunner: add a block-diffusion drafting session
A DFlash draft proposes a whole block per forward, which doesn't fit
the MTP session's one-token-per-call chain. Add a second drafting
session for block drafts: committed target features write straight
into the draft's context caches, and each round drafts a block in one
forward and samples it in one batched call, rolling the block's cache
entries back with the same mechanism speculative rounds use on the
target caches. The depth controller's search is capped at the deepest
draft the drafter can produce, since a depth it can never measure
would otherwise always look best.
2026-08-07 19:33:35 -07:00
Jesse Gross 0fcfc99ea0 sample: define multi-row distributions without a draft chain
Distribution aligns its rows with the end of the draft chain, so when
the caller passes no chain, every row sees the slot history unchanged.
That case already worked; only the row-count guard rejected it. The
guard now applies only when a chain is present, which is where more
rows than chain positions would silently drop history. A block drafter
needs the chainless case to sample its whole proposal batch in one
call.
2026-08-07 19:33:35 -07:00
Jesse Gross e7fbd528f7 mlxrunner: let each model declare the cache slots it needs
The runner used to build caches by probing the model for an optional
NewCaches method, with one KV cache per layer as the fallback. A model
with a draft head appended the draft's cache slots to its own list, and
the speculative engine later recovered the two groups by comparing slot
identities, panicking when the lists didn't line up.

NewCaches is now a required method on both the model and the draft, and
each returns only the slots it writes. The runner concatenates the two
lists for the prefix cache and passes them to the speculative engine
separately, so snapshots and rollback apply to the target's slots and
the draft forward receives both groups as arguments. The identity
comparison, its panics, and the per-request rebinding are gone; the two
groups are fixed at load time.
2026-08-07 19:33:35 -07:00
Jesse Gross 2f84872ce0 mlxrunner: return the draft-conditioning state from a model forward
A draft model conditions on state that the target produces during its
own forward pass. For an MTP head or an assistant model that state is
the final hidden state; for a block draft it is the concatenated
outputs of several layers. The choice belongs to the model, so Forward
now returns the conditioning state along with the hidden state to
unembed. Models without a special conditioning state return the final
hidden state for both, and the decode paths hand the value to the
drafter without looking at it.
2026-08-07 19:33:35 -07:00
Parth Sareen f91cb0d6a7 agent/tui: stream thinking traces (#17611) 2026-08-07 13:11:45 -07:00
Eva H 8dd34b77d1 cmd/tui: restore launcher integrations menu (#17595) 2026-08-07 11:11:16 -07:00
Daniel Hiltgen 35f71382de openai: expand namespace tool declarations in the responses API (#17593)
The Responses API groups related tools by domain: a tool with type "namespace" carries the real function definitions in a nested tools array. The conversion dropped that array, leaving the model a single schema-less pseudo-function and making every namespaced call undeclarable.

Expand namespace declarations into their member functions with namespace-qualified names, since api.Tool carries only a flat function name.

Relates to #15921: full Responses API parity also wants the namespace preserved as a separate field on tool calls in the output, which needs new api surface and is not addressed here.
2026-08-07 09:52:37 -07:00
Jesse Gross 144893850f mlxrunner: stop cache rewind refills from corrupting later lazy snapshots
A lazy KV snapshot indexes into the cache's live buffer instead of owning
a copy, so it must be copied out before an append overwrites the slots it
names. appendKV checked for that only on the first append after a rewind,
and only against that append's own range: a still-lazy snapshot further
ahead in the buffer was overwritten without a copy when a later append
reached it. This happens when a request reuses a short prefix of a longer
cached conversation and prefills past one of the old conversation's
snapshots; restoring that snapshot later silently serves the new request's
KV in place of the old conversation's.

Scan every append instead. The overlap test already limits copies to
snapshots the current write clobbers, and appends outside a rewind refill
sit above every snapshot, so the steady-state scan walks a short list and
finds nothing. This restores the invariant Restore's lazy fast path relies
on: a snapshot still in its lazy state has never been overwritten.
2026-08-05 16:31:56 -07:00
Daniel Hiltgen 26936bea45 ci: fix race in darwin build (#17578)
Do vendoring work once at the top level build to avoid 2 nested builds fighting
with eachother.
2026-08-05 11:10:18 -07:00
Daniel Hiltgen 43983edf18 progress: fix data races on ticker, states, spinner, and bar state (#17445)
* progress: fix data races on ticker, states, spinner, and bar state

NewProgress spawned start() which wrote p.ticker while stop() read and
cleared it with no synchronization; stop() and StopAndClear() also read
p.states and p.pos outside p.mu, Spinner's start() goroutine raced
Stop() and String() on s.value/s.stopped/s.ticker, and Bar.Set raced
Bar.String on currentValue/stopped/buckets (callback goroutine vs the
render goroutine). Detected by go test -race across cmd and cmd/launch
(~20 warnings; the Bar race is latent — never flagged because tests
don't interleave it, but real in production pull/push progress).

Create tickers before spawning the render goroutines and pass the
channel in, guard Progress internals with p.mu throughout stop() (via a
renderLocked core), and give Spinner and Bar their own mutexes.

* use a more idiomatic channel based done signal
2026-08-04 15:06:15 -07:00
Daniel Hiltgen c82ebbd5bf llama.cpp update (#17545) 2026-08-04 09:51:52 -07:00
Bruce MacDonald 8edecb5c69 openai: match openai's streaming wire format for chat completions (#17485)
Reworked our /v1/chat/completions streaming to match what api.openai.com actually sends,
chunk-for-chunk, based on captures I took of real OpenAI traffic.

What changed:
 - finish_reason now goes on its own chunk with an empty delta {}, instead of riding on the last content
   chunk. Precedence is length > tool_calls > the response's done reason > stop.
 - role is only sent on the first chunk of a stream, not on every chunk.
 - With stream_options.include_usage, usage goes out on its own chunk with choices: [] after the finish
   chunk.
 - A truncated response keeps finish_reason: "length" even when tool calls were streamed — it used to get
   overwritten with "tool_calls". Fixed in both streaming and non-streaming paths.
 - The metrics-only trailer response (empty message at end of stream) no longer produces a stray
   delta:{"content":""} chunk before the finish chunk. A wholly empty completion still opens with a role
   chunk.
 - Every chunk in a stream shares one timestamp, from the response's CreatedAt.
2026-08-03 15:36:57 -07:00
Devon Rifkin 8d8c701d6a Merge pull request #17483 from ollama/drifkin/suggest-cloud
cmd: suggest :cloud when a model has no default tag
2026-07-31 14:28:04 -07:00
Daniel Hiltgen b63eed94b6 app/updater: drain background update-check goroutine before returning (#17446)
DownloadNewRelease spawned a background checkForUpdate loop that read
package-level knobs (UpdateCheckInterval et al.) and returned without
waiting for it, so under -race the next test rewrote those globals while
the orphaned goroutine was still reading them. waitDownloadIdle (from

Cancel and WaitGroup-drain the loop before DownloadNewRelease returns,
and have TestCancelOngoingDownload join its download goroutine so the
drain is observable before the test exits.
2026-07-31 10:42:40 -07:00
Jesse Gross 4f9d09ef52 qwen3_5: load and run the MTP head as a speculative draft
Load the MTP head from the mtp.* tensors instead of freeing them and implement
Draft to propose one token per step, gated solely on the tensors being
present; a model whose head ships inline is its own draft via base.SelfDraft.
The runtime keeps sole ownership of the +1 RMSNorm shift (conversion passes
tensors through verbatim), and the head's norms shift under the same
original-format detection as the main stack, so nothing shifts twice.
2026-07-31 10:18:54 -07:00
Jesse Gross ba8f2a324d nn/recurrent: run the gated-delta step in one launch
Decode-length scans spend more time in launch gaps than math: the q/k
norms, decay gate, and recurrence each dispatched separately per layer.
Fuse the step into one Metal kernel over the activated conv output,
with per-token boundary states available from the same pass. The graph
implementation remains as the fallback and contract-miss path, and pins
the kernel bit-for-bit in the parity test.
2026-07-31 10:18:54 -07:00
Jesse Gross 721f05049d nn/recurrent: activate the conv output in CausalConv1D
The activation belongs to the conv stage: downstream consumers see
activated values however the conv is computed. WithConvSiLU routes to a
fused depthwise conv+SiLU kernel when the conv fits its contract and
the same computation as graph ops otherwise; cached conv state is the
raw input tail, unaffected by activation placement.
2026-07-31 10:18:54 -07:00
Jesse Gross accd6d656a mlx: factor custom GPU kernel scaffolding into helpers
Each custom kernel repeated the same host-side creation and launch
boilerplate plus a CUDA-then-Metal-then-graph dispatch at every call
site. gpuKernel declares the sources (either backend may be absent) and
a graph fallback; run executes the first that works.
2026-07-31 10:18:54 -07:00
Jesse Gross bd3f22e2f7 qwen3_5: pack GDN input projections into one layout at load
Split checkpoints ran four input projections per recurrent layer, and
native combined checkpoints paid a per-forward slice-and-concat to
rebuild the contiguous qkv rows the causal conv consumes. Normalize
both at load to packed [q|k|v|z] and [beta|alpha] rows: split tensors
concatenate, native interleaved tensors permute once. The forward keeps
a single projection path, and the packed rows are the layout a fused
scan can consume directly.

Pairs with mismatched quantization dequantize before packing rather
than keeping a split fallback path alive.
2026-07-31 10:18:54 -07:00
Jesse Gross 5db07cad71 mlx: apply global scales in Dequantize
The C-level dequantize accepts a global_scale argument but rejects it
on the Metal backend, so dequantize-fallback sites hand-rolled the
same post-multiply. Take the scale in the Go wrapper and apply it on
top of the op, cast back to the output dtype. The quantized embedding
passes its scale; laguna's expert paths keep their own multiplies,
which shape per-expert scales and differ on result dtype.
2026-07-31 10:18:54 -07:00
Jesse Gross acf96e7ab7 mlx: read scalar items at the array's element width
mlx item<T> reinterprets without checking the dtype, so Array.Int's
8-byte read of int32 scalars took in neighboring pool bytes — masked by
Metal's zeroed allocations, corrupting token IDs on CUDA's warm pool.
Read at the element's width.
2026-07-31 10:18:54 -07:00
Daniel Hiltgen a199313eb3 mlx update (#17476) 2026-07-30 10:16:29 -07:00
Daniel Hiltgen b205993ed4 CI: enable lint on the whole tree (#17457)
golangci-lint ran with only-new-issues, which filters findings down to the
lines a PR adds. That silently drops any issue a diff introduces at a
distance, where the report anchors to a line the diff never touched.
CI now is enabled to scan all files.  This PR also fixes the last few
straggler lint glitches outside of integration, which I'll tackle
in a follow up PR.
2026-07-29 16:25:38 -07:00
Daniel Hiltgen 9ea503f505 lint: clean up current tree (#17456) 2026-07-29 15:33:28 -07:00
Jesse Gross 3ff2dcb649 mlxrunner: count every speculative round and log stats at info
The per-request stats are the main diagnostic for speculative
throughput, so log them at info; the controller line stays debug.
Recording chosen depths at the next beginRound dropped rounds with no
successor, so record at endRound and count resume as a depth-0 round.
2026-07-29 14:55:19 -07:00
Jeffrey Morgan 4713800b08 imagegen: remove MLX image generation code (#16615)
Remove the x/imagegen tree (MLX image generation engine, Flux2/zimage
models, cache, C bindings) and all imagegen integration points:

- server: drop imagegen routes, scheduling, and generate handling
- api/cmd/docs: remove image generation API surface and docs
- middleware/openai: remove image endpoint support
- integration: remove imagegen test suites
- x/create: adopt the rewritten create pipeline from main; drop
  imagegen create path (CreateImageGenModel, IsTensorModelDir,
  model_index.json detection, Flux2KleinPipeline vision hack)
- retain x/imagegen/manifest (Ollama-store safetensors manifest
  loader), still used by x/mlxrunner and x/create/client
- fix Windows MLX dl.dll install, MLX CMake version path, and the
  show command after removing safetensors models
2026-07-28 15:35:28 -07:00
Parth Sareen 0e2e34aa86 cmd/tui: improve prompt debug rendering (#17334) 2026-07-27 17:29:18 -07:00
Parth Sareen 76929b0a8a agent: accept file mentions on Enter (#17384) 2026-07-27 17:28:55 -07:00
Parth Sareen bf7be180e3 tui: avoid table detection for pipe prose (#17424) 2026-07-27 13:42:08 -07:00
Daniel Hiltgen eec8e0b945 ci: on release builds dont fail fast (#17413)
If we have one flake, don't stop other jobs that will most likely work so when
we re-run failed jobs, only the flake and dependents need to be run.  This should
help reduce the time it takes to get past a flake and finish a release build.
2026-07-27 08:01:04 -07:00
Daniel Hiltgen be7572e2cf mlx update (#17397) 2026-07-26 16:59:47 -07:00
Daniel Hiltgen 64ee2f9847 model: add Laguna MLX support (#17237)
* model: add Laguna MLX support

Add Laguna XS 2, XS 2.1, and S 2.1 support to the MLX model and create paths.

Read the source config to apply one quantization policy across dense and routed MoE layers. Keep the tied output head and router at source precision, quantize supported attention and expert projections, selectively promote sensitive expert down projections, and emit per-tensor metadata for mixed quantization blobs.

Correct dense expert loading, BF16 source-layout handling, expert global-scale shapes and dtypes, routing-score scaling, and mixed-precision expert dispatch. Gate/up and down projections select quantized or dense execution independently so promoted BF16 down projections do not force quantized gate/up weights through the dense fallback.

Optimize the forward pass with compatible gate/up fusion, sorted standard GatherMM and GatherQMM operations for larger prefills, model-local mlx.Compile closures for elementwise MoE work, and cache-backed 512-token prefill chunks. This keeps the implementation on maintained MLX operations without custom kernels.

Add focused tests for Laguna configuration variants, quantization policy and metadata, dense and routed expert loading, mixed-precision dispatch, compiled-versus-eager parity, fused projections, routing, and prefill chunking.

* review comments and S 2.1 performance fixes

Address renderer/parser selection and mixed-precision expert quantization review feedback.

Keep Laguna weights resident on Metal to prevent repeated paging of its large, sparsely accessed expert buffers. Scope this policy to Laguna GPU execution.

Remove obsolete 512-token prefill chunking now that the runner's 2048-token path is faster.

* review comments addressed

* fix create
2026-07-24 18:24:53 -07:00
Jesse Gross 132e0ca25d x/create: quantize a draft model's output head at the requested type
Draft token embeddings were kept at source precision. A draft that
reuses its embedding as the output projection (the gemma4 assistant)
then reads the whole 537MB bf16 tensor on every draft step — about half
the step's cost. Draft quality only affects how many drafts are
accepted, so the output head now takes the requested type instead of the
8-bit type that protects a target's output quality.

gemma4:26b-mlx, M5 Max: MTP code decode 148 -> 157 tok/s (+26% -> +37%
over plain); prose goes from roughly zero to +2-5%; acceptance unchanged.
2026-07-24 17:26:58 -07:00
Daniel Hiltgen 9eef4a7195 mlx: keep loaded model memory resident (#17367)
Configure Metal residency after the MLX runner materializes model weights.

Wire up to the smaller of active model memory and the recommended working set, leaving pageable headroom for KV caches and request allocations. If residency setup fails, warn and continue with pageable memory.

Expose recoverable MLX C API errors and verify that an oversized wired limit preserves the previous state and leaves subsequent evaluation usable.
2026-07-24 15:34:32 -07:00
Parth Sareen 3f07e022ac cmd/tui: agent system prompt command (#17296) 2026-07-24 14:44:25 -07:00
Parth Sareen 551809688b agent: permission skill loading (#17304) 2026-07-24 14:37:21 -07:00
Jesse Gross 08edcb8f2c qwen3_5: gather packed gate_up experts in one launch
Gathering gate and up separately cost a third expert gather per MoE
layer. Keep gate_up packed as one tensor, joining it at load when the
checkpoint ships the halves separately, and split the gather's output
instead.

Output is byte-identical; decode is 4% faster (7.89 -> 7.58 ms/token on
M5 Max) and prefill 9% faster.
2026-07-24 14:34:56 -07:00
Jesse Gross d6f69da04d qwen3_5: decode each expert tensor with its own quantization format
The expert matmuls decoded with the model-wide format, so models whose
experts are quantized differently from the rest of the weights could
not run.
2026-07-24 14:34:56 -07:00
Daniel Hiltgen 6cd40001a9 server: fix ps data race on scheduler loaded map (#17376)
PsHandler iterated sched.loaded without holding loadedMu, racing with
scheduler goroutines that mutate the map. It also read runnerRef fields
(model, llama, expiresAt) that unload() and the expiration path mutate
under refMu, so a concurrently unloading runner could nil model out from
under the handler.

Instead of adding locking in routes.go, give the scheduler a small
snapshot API: loadedModels() copies the runner list under loadedMu, then
captures each runner's reporting fields under its refMu, respecting the
refMu-before-loadedMu lock ordering used by the expiration path. The
zero-expiresAt estimate for still-loading models moves into the
scheduler too, since it exists because of scheduler behavior.

Also remove the dead code Scheduler.GetRunner
2026-07-24 13:23:49 -07:00
Daniel Hiltgen a84b315e7b test: harden flaky updater and transfer unit tests (#17378)
app/updater: TestBackgoundChecker / TestAutoUpdateDisabledSkipsDownload hit 'TempDir RemoveAll cleanup: directory not empty' on macOS because the background checker goroutine keeps writing staged files into UpdateStageDir while t.TempDir cleanup runs. The checker's context is cancelled by the time cleanup runs, and after cancellation a new download cannot reach the filesystem (DownloadNewRelease aborts at its HEAD request before any write), so it suffices to wait for any in-flight download to drain. Add a test-only waitDownloadIdle helper (polls the existing cancelDownload sentinel under its lock) and register it via t.Cleanup so TempDir cleanup runs after staged-file handles close. No production code changes.

x/transfer: TestDownloadParallelism asserted elapsed <= 1s against 50ms-per-blob delays, too tight for Windows hosted runners' ~15ms timer granularity and shared-runner jitter. Each blob costs two server sleeps (resolve GET + body GET), so model the serial baseline from the deterministic request count, raise per-blob latency to 100ms so timer quantization is a small fraction of each delay, and key the budget to 75% of the serial baseline so the check still proves parallelism while tolerating jitter.
2026-07-24 13:23:30 -07:00
Jesse Gross 83d4311ffe x/create: quantize lm_head at 8-bit in the requested family
The lm_head rule was asymmetric: the fp modes kept an untied head at
source precision (even under mxfp8, leaving it the only bf16 matmul in
the model), while int4 quantized it at 4 bits with no promotion. The
tied-embedding overrides (gemma4, cohere2moe) already resolve the head
to the 8-bit family type and hold quality close to bf16.

Apply the same decision to untied heads: the 8-bit type in the
requested family when it fits the shape, source precision otherwise.
int4 now promotes the head to int8, and the fp modes quantize it to
mxfp8 instead of keeping bf16.
2026-07-23 17:46:09 -07:00
Parth Sareen fce745fe5e agent: import skills from coding agents (#17294) 2026-07-22 22:40:25 -07:00
Daniel Hiltgen 1fd1ccf7ad model: align Laguna with upstream llama.cpp (#17335)
Update llama.cpp to pick up upstream Laguna implementation and remove Ollama's local Laguna implementation. Retain a narrow Metal-only scaling workaround for routed-MoE prompt overflow.

Translate older Ollama GGUF attention-gate and SWA metadata names so existing models continue to load.
2026-07-22 17:09:18 -07:00
Michael Yang efb7e3c55e docs: update retirements (#17289) 2026-07-22 14:24:11 -07:00
Daniel Hiltgen b517b9bd01 model/parsers: finalize incomplete GLM tool calls (#17250)
The GLM parser buffered tool calls until it observed </tool_call>, but ignored the terminal done signal. If the model omitted or partially emitted the outer closing tag, Ollama returned a successful empty response instead of a tool call or an actionable error, leaving coding agents unable to continue.

On end-of-stream, finalize only structurally complete calls for declared tools with all required arguments. Complete calls missing only the outer delimiter now proceed through the existing parser, while genuinely truncated calls return an explicit error rather than being silently dropped.

Fixes #16497
2026-07-22 13:53:47 -07:00
Daniel Hiltgen 479664e7aa mlx update (#17332) 2026-07-22 13:36:49 -07:00
Daniel Hiltgen a51df81573 test: revamp integration test entrpoints (#16560)
This refactors the existing integration tests into 3 priumary groups: fast,
release, and library.  It also refines some of the release tests to drop some
of the older models and pick up newer models, while retaining the broad
coverage in the library group.
2026-07-21 16:06:38 -07:00
Daniel Hiltgen a18c230189 model: add Laguna v8 chat support and fix Metal inference (#17291)
Add a laguna-v8 renderer/parser matching the Laguna XS 2.1 template, and fix v2 handling of embedded thinking and structured tool arguments.

Prevent FP16 overflow in Metal's quantized routed-MoE prefill path by scaling the linear branch and folding the inverse into the routing scale. Other backends and token-generation paths are unchanged.

Add comprehensive v2/v8 Jinja parity and parser tests.
2026-07-21 16:06:29 -07:00
Daniel Hiltgen e21d5327b0 CI: fix missing CUDA v13.4 sub-package (#17288)
Needed for cross-compiling WoA
2026-07-21 12:25:10 -07:00
Jhye 4d1b53e6fb server: detect download stalls before the first byte (#17259)
* server: detect download stalls before the first byte

* server: keep stall timeout out of download API
2026-07-21 11:28:19 -07:00
Daniel Hiltgen 6100aca085 win: support CUDA on Windows ARM64 (#16931) 2026-07-21 10:53:30 -07:00
Daniel Hiltgen 72116bafb3 llama: enable dio on linux CUDA/ROCm iGPUs (#17286)
Avoid double memory consumption by enabling direct IO for iGPUs
2026-07-21 10:53:08 -07:00
Patrick Devine e2c2edcc27 docs: add renderer/parser fields to the API docs (#17275) 2026-07-20 16:22:46 -07:00
Daniel Hiltgen de1ce45913 cuda: add CC 10.0 for linux in CUDA v12 (#17025)
Add compute capability 10.0 to the Linux CUDA v12 preset so B200-class devices can use the cuda_v12 backend with drivers that do not meet the CUDA v13 minimum.

Fixes #12583
2026-07-20 13:09:36 -07:00
Daniel Hiltgen 51fc00122b build: bump Linux toolchain to GCC 13 (#17244)
GCC 11 builds broken AMX code which causes the Sapphire Rapids CPU backend to crash.

Fixes #17006
Fixes #17205
2026-07-20 11:54:39 -07:00
Daniel Hiltgen 445284b428 MLX update (#17189) 2026-07-20 11:54:24 -07:00
Parth Sareen e8f7c93a0b launch: update Hermes integration (#17202) 2026-07-20 11:28:01 -07:00
Parth Sareen 0de38190d7 cmd/tui/chat: render bold emphasis consistently across markdown (#17224) 2026-07-20 11:25:43 -07:00
Parth Sareen 681dfaedcc cmd: remove standalone agent command (#17229) 2026-07-20 11:25:31 -07:00
Parth Sareen 9893d39218 cmd: complete slash commands before submitting (#17230) 2026-07-20 11:25:12 -07:00
Parth Sareen 5ba17e6fdf agent/tui: remove redundant context-window refreshes from event loop (#17241) 2026-07-20 11:25:01 -07:00
Parth Sareen 6f3b997dec cmd: route root command server start through checkServerHeartbeat (#17245)
The bare `ollama` command (and `ollama launch` with no integration) used a
bespoke `ensureServerRunning` that forked `ollama serve` directly and polled
its heartbeat forever (no timeout, no platform-aware launch). Every other
subcommand (`ollama run`, `ollama pull`, `ollama launch <integration>`, ...)
goes through `checkServerHeartbeat` -> `startApp`, so the root command behaved
differently and could hang indefinitely.

Route `runInteractiveTUI` through `checkServerHeartbeat(cmd, nil)` — the same
path `ollama launch <thing>` uses — so the root command is consistent and no
longer runs an unbounded server-spawn loop. `ensureServerRunning` and its
`backgroundServerSysProcAttr` helpers (only it referenced them) are removed,
along with the now-unused `os/exec` import.

The platform `startApp`/`waitForServer` paths are unchanged, so behavior on
macOS/Windows is identical to the other subcommands, and on Linux the root
command now errors the same way the subcommands already do when no server is
running.
2026-07-20 11:24:25 -07:00
Daniel Hiltgen cc62676656 llama.cpp update (#17186) 2026-07-20 11:21:09 -07:00
359 changed files with 33519 additions and 33397 deletions

No files matched your search

+31 -1
View File
@@ -93,6 +93,7 @@ jobs:
windows-depends:
needs: setup-environment
strategy:
fail-fast: false
matrix:
os: [windows]
arch: [amd64]
@@ -124,6 +125,22 @@ jobs:
- '"nvvm"'
- '"nvptxcompiler"'
cuda-version: '13.0'
- os: windows
arch: amd64
preset: 'CUDA 13 ARM64'
build-steps: cuda13Arm64Cross
install: https://packages.nvidia.com/prerelease/cuda/13.4.0/local_installers/cuda_13.4.0_windows_x86_64.exe
cuda-components:
- '"cudart"'
- '"cudart_cross"'
- '"nvcc"'
- '"nvcc_cross"'
- '"cublas_cross"'
- '"cublas_dev"'
- '"crt"'
- '"nvvm"'
- '"nvptxcompiler"'
cuda-version: '13.4'
- os: windows
arch: amd64
preset: 'ROCm 7'
@@ -198,8 +215,18 @@ jobs:
name: Install CUDA ${{ matrix.cuda-version }}
run: |
$ErrorActionPreference = "Stop"
$ProgressPreference = 'SilentlyContinue'
if ("${{ steps.cache-install.outputs.cache-hit }}" -ne 'true') {
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
for ($attempt = 1; $attempt -le 3; $attempt++) {
try {
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
break
} catch {
if ($attempt -eq 3) { throw }
Write-Host "CUDA installer download attempt $attempt failed: $($_.Exception.Message); retrying in 15s"
Start-Sleep -Seconds 15
}
}
$subpackages = @(${{ join(matrix.cuda-components, ', ') }}) | Foreach-Object {"${_}_${{ matrix.cuda-version }}"}
Start-Process -FilePath .\install.exe -ArgumentList (@("-s") + $subpackages) -NoNewWindow -Wait
}
@@ -434,6 +461,7 @@ jobs:
linux-depends:
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
@@ -515,6 +543,7 @@ jobs:
# and just assembles, runs the Go build, pushes the final image, and extracts release bundles.
docker-build-push:
strategy:
fail-fast: false
matrix:
include:
- os: linux
@@ -665,6 +694,7 @@ jobs:
# Merge Docker images for the same flavor into a single multi-arch manifest
docker-merge-push:
strategy:
fail-fast: false
matrix:
suffix: ['', '-rocm']
runs-on: linux
+27 -1
View File
@@ -321,6 +321,22 @@ jobs:
- '"nvvm"'
- '"nvptxcompiler"'
cuda-version: '13.0'
- os: windows
arch: amd64
preset: 'CUDA 13 ARM64'
build-steps: cuda13Arm64Cross
install: https://packages.nvidia.com/prerelease/cuda/13.4.0/local_installers/cuda_13.4.0_windows_x86_64.exe
cuda-components:
- '"cudart"'
- '"cudart_cross"'
- '"nvcc"'
- '"nvcc_cross"'
- '"cublas_cross"'
- '"cublas_dev"'
- '"crt"'
- '"nvvm"'
- '"nvptxcompiler"'
cuda-version: '13.4'
- os: windows
arch: amd64
preset: 'ROCm 7'
@@ -365,8 +381,18 @@ jobs:
name: Install CUDA ${{ matrix.cuda-version }}
run: |
$ErrorActionPreference = "Stop"
$ProgressPreference = 'SilentlyContinue'
if ("${{ steps.cache-install.outputs.cache-hit }}" -ne 'true') {
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
for ($attempt = 1; $attempt -le 3; $attempt++) {
try {
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
break
} catch {
if ($attempt -eq 3) { throw }
Write-Host "CUDA installer download attempt $attempt failed: $($_.Exception.Message); retrying in 15s"
Start-Sleep -Seconds 15
}
}
$subpackages = @(${{ join(matrix.cuda-components, ', ') }}) | Foreach-Object {"${_}_${{ matrix.cuda-version }}"}
Start-Process -FilePath .\install.exe -ArgumentList (@("-s") + $subpackages) -NoNewWindow -Wait
}
-2
View File
@@ -416,5 +416,3 @@ jobs:
run: go test -count=1 -tags updater_live ./app/...
- uses: golangci/golangci-lint-action@v9
with:
only-new-issues: true
+8 -8
View File
@@ -15,9 +15,9 @@ FROM scratch AS local-mlx
FROM scratch AS local-mlx-c
FROM --platform=linux/amd64 rocm/dev-almalinux-8:${ROCMVERSION}-complete AS base-amd64
RUN dnf install -y yum-utils ccache gcc-toolset-11-gcc gcc-toolset-11-gcc-c++ gcc-toolset-11-binutils \
RUN dnf install -y yum-utils ccache gcc-toolset-13-gcc gcc-toolset-13-gcc-c++ gcc-toolset-13-binutils \
&& yum-config-manager --add-repo https://developer.download.nvidia.com/compute/cuda/repos/rhel8/x86_64/cuda-rhel8.repo
ENV PATH=/opt/rh/gcc-toolset-11/root/usr/bin:$PATH
ENV PATH=/opt/rh/gcc-toolset-13/root/usr/bin:$PATH
FROM --platform=linux/arm64 almalinux:8 AS base-arm64
# install epel-release for ccache
@@ -42,8 +42,8 @@ ENV LDFLAGS=-s
#
FROM base AS cpu-deps
RUN dnf install -y gcc-toolset-11-gcc gcc-toolset-11-gcc-c++
ENV PATH=/opt/rh/gcc-toolset-11/root/usr/bin:$PATH
RUN dnf install -y gcc-toolset-13-gcc gcc-toolset-13-gcc-c++
ENV PATH=/opt/rh/gcc-toolset-13/root/usr/bin:$PATH
FROM base AS cuda-12-deps
ARG CUDA12VERSION=12.8
@@ -91,8 +91,8 @@ RUN --mount=type=cache,target=/root/.ccache \
&& for lib in \
/usr/lib64/libgomp.so* \
/usr/lib64/libomp.so* \
/opt/rh/gcc-toolset-11/root/usr/lib64/libgomp.so* \
/opt/rh/gcc-toolset-11/root/usr/lib64/libomp.so*; do \
/opt/rh/gcc-toolset-13/root/usr/lib64/libgomp.so* \
/opt/rh/gcc-toolset-13/root/usr/lib64/libomp.so*; do \
[ -e "$lib" ] && cp -a "$lib" dist/lib/ollama/ || true; \
done
@@ -124,7 +124,7 @@ FROM scratch AS publish-llama-server-cuda_v13
COPY --from=llama-server-cuda_v13 dist/lib/ollama /lib/ollama/
FROM rocm-7-deps AS llama-server-rocm_v7_2
ENV CC=clang CXX=clang++
ENV CC=clang CXX=clang++ CXXFLAGS=--gcc-toolchain=/opt/rh/gcc-toolset-13/root/usr
COPY LLAMA_CPP_VERSION .
COPY llama/server llama/server
COPY llama/compat llama/compat
@@ -213,7 +213,7 @@ ENV CGO_LDFLAGS="-L/usr/local/cuda-13/lib64 -L/usr/local/cuda-13/targets/x86_64-
WORKDIR /go/src/github.com/ollama/ollama
COPY CMakeLists.txt CMakePresets.json .
COPY cmake cmake
COPY x/imagegen/mlx x/imagegen/mlx
COPY x/mlxrunner/mlx x/mlxrunner/mlx
COPY go.mod go.sum .
COPY MLX_VERSION MLX_C_VERSION .
RUN curl -fsSL https://golang.org/dl/go$(awk '/^go/ { print $2 }' go.mod).linux-$(case $(uname -m) in x86_64) echo amd64 ;; aarch64) echo arm64 ;; esac).tar.gz | tar xz -C /usr/local
+1 -1
View File
@@ -1 +1 @@
b9888
b10434
+1 -1
View File
@@ -1 +1 @@
de7b4ed986b6d6f55b8ace5e73c24d1ca0bea89b
adf21deabd32c4fe3726ab2d373ff2165c3f27d6
+1 -1
View File
@@ -65,7 +65,7 @@ To launch a specific integration:
ollama launch claude
```
Supported integrations include [Claude Code](https://docs.ollama.com/integrations/claude-code), [Codex](https://docs.ollama.com/integrations/codex), [Copilot CLI](https://docs.ollama.com/integrations/copilot-cli), [Droid](https://docs.ollama.com/integrations/droid), and [OpenCode](https://docs.ollama.com/integrations/opencode).
Supported integrations include [Claude Code](https://docs.ollama.com/integrations/claude-code), [Codex](https://docs.ollama.com/integrations/codex), [Copilot CLI](https://docs.ollama.com/integrations/copilot-cli), [DeepSeek Harness](https://docs.ollama.com/integrations/deepseek-harness), [Droid](https://docs.ollama.com/integrations/droid), and [OpenCode](https://docs.ollama.com/integrations/opencode).
### AI assistant
-7
View File
@@ -474,7 +474,6 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
batch := toolBatchResult{
messages: make([]api.Message, 0, len(calls)),
}
projectedMessages := append([]api.Message(nil), messages...)
// Pre-compute the full-history token estimate once per batch instead of
// re-marshaling the entire history for each tool call. Per-call deltas
// (tool messages already appended this batch) are tracked in batchTokens
@@ -530,7 +529,6 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
for _, plan := range plans {
msg := s.toolMessageForContext(plan.toolName, plan.call.ID, content, opts, historyTokens+batchTokens)
batch.messages = append(batch.messages, msg)
projectedMessages = append(projectedMessages, msg)
batchTokens += estimateMessagesTokens([]api.Message{msg})
deniedContent := msg.Content
if emitErr := s.emit(newToolFinished(meta, "denied", plan.call.ID, plan.toolName, "", plan.args, deniedContent, deniedContent)); emitErr != nil {
@@ -559,7 +557,6 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
content := fmt.Sprintf("Error: unknown tool: %s", toolName)
msg := s.toolMessageForContext(toolName, call.ID, content, opts, historyTokens+batchTokens)
batch.messages = append(batch.messages, msg)
projectedMessages = append(projectedMessages, msg)
batchTokens += estimateMessagesTokens([]api.Message{msg})
content = msg.Content
if toolOutputFullyOmitted(content) {
@@ -580,7 +577,6 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
rawContent := fmt.Sprintf("Error: %v", err)
msg := s.toolMessageForContext(toolName, call.ID, rawContent, opts, historyTokens+batchTokens)
batch.messages = append(batch.messages, msg)
projectedMessages = append(projectedMessages, msg)
batchTokens += estimateMessagesTokens([]api.Message{msg})
content := msg.Content
if toolOutputFullyOmitted(content) {
@@ -609,7 +605,6 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
msg := s.toolMessageForContext(toolName, call.ID, rawContent, opts, historyTokens+batchTokens)
batch.messages = append(batch.messages, msg)
projectedMessages = append(projectedMessages, msg)
batchTokens += estimateMessagesTokens([]api.Message{msg})
content := msg.Content
@@ -637,7 +632,6 @@ func (s *Session) disabledToolCalls(ctx context.Context, runID string, opts RunO
batch := toolBatchResult{
messages: make([]api.Message, 0, len(calls)),
}
projectedMessages := append([]api.Message(nil), messages...)
historyTokens := s.estimateRunPromptTokens(opts, messages)
batchTokens := 0
for _, call := range calls {
@@ -645,7 +639,6 @@ func (s *Session) disabledToolCalls(ctx context.Context, runID string, opts RunO
args := call.Function.Arguments.ToMap()
msg := s.toolMessageForContext(toolName, call.ID, toolExecutionDisabledMessage, opts, historyTokens+batchTokens)
batch.messages = append(batch.messages, msg)
projectedMessages = append(projectedMessages, msg)
batchTokens += estimateMessagesTokens([]api.Message{msg})
if emitErr := s.emitIgnoringCanceled(ctx, newToolFinished(meta, "disabled", call.ID, toolName, "", args, msg.Content, msg.Content)); emitErr != nil {
return toolBatchResult{}, emitErr
+375
View File
@@ -1,8 +1,10 @@
package agent
import (
"bytes"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
@@ -271,6 +273,354 @@ type skillRoot struct {
path string
}
// SkillImportResult describes one import attempt. Failed skills do not prevent
// other valid skills in the same source root from being imported.
type SkillImportResult struct {
Source string
SourceDir string
Destination string
Imported []string
Existing []string
Failures []SkillImportFailure
}
// SkillImportFailure identifies a source skill that was deliberately skipped.
// The destination is never changed for a failed skill.
type SkillImportFailure struct {
Name string
Err error
}
// ImportSkills imports skills from a conventional coding-agent source into the
// canonical Ollama skills directory. Supported sources are codex, claude, and
// pi. Existing skills are left untouched: an identical directory is reported
// as existing, and a differing one is reported as a conflict.
func ImportSkills(source string) (SkillImportResult, error) {
home, err := os.UserHomeDir()
if err != nil {
return SkillImportResult{}, fmt.Errorf("resolve home directory: %w", err)
}
destination, err := SkillsDir()
if err != nil {
return SkillImportResult{}, fmt.Errorf("resolve Ollama skills directory: %w", err)
}
return importSkillsFromRoots(source, conventionalSkillImportRoots(home), destination)
}
func conventionalSkillImportRoots(home string) map[string]string {
return map[string]string{
"codex": filepath.Join(home, ".codex", "skills"),
"claude": filepath.Join(home, ".claude", "skills"),
"pi": filepath.Join(home, ".pi", "agent", "skills"),
}
}
func importSkillsFromRoots(source string, roots map[string]string, destination string) (SkillImportResult, error) {
source = strings.ToLower(strings.TrimSpace(source))
sourceDir, ok := roots[source]
if !ok {
return SkillImportResult{}, fmt.Errorf("unknown skill source %q", source)
}
return importSkillsFromDir(source, sourceDir, destination)
}
func importSkillsFromDir(source, sourceDir, destination string) (SkillImportResult, error) {
result := SkillImportResult{Source: source, SourceDir: sourceDir, Destination: destination}
info, err := os.Lstat(sourceDir)
if errors.Is(err, fs.ErrNotExist) {
return result, nil
}
if err != nil {
return result, fmt.Errorf("inspect %s skills directory: %w", source, err)
}
if info.Mode()&os.ModeSymlink != 0 {
return result, fmt.Errorf("inspect %s skills directory: symlinks are not supported", source)
}
if !info.IsDir() {
return result, fmt.Errorf("inspect %s skills directory: not a directory", source)
}
entries, err := os.ReadDir(sourceDir)
if err != nil {
return result, fmt.Errorf("read %s skills directory: %w", source, err)
}
for _, entry := range entries {
name := entry.Name()
path := filepath.Join(sourceDir, name)
if entry.Type()&os.ModeSymlink != 0 {
result.Failures = append(result.Failures, SkillImportFailure{Name: name, Err: errors.New("symlinked skill directories are not supported")})
continue
}
info, err := entry.Info()
if err != nil {
result.Failures = append(result.Failures, SkillImportFailure{Name: name, Err: fmt.Errorf("inspect source: %w", err)})
continue
}
if !info.IsDir() {
continue
}
if !skillName.MatchString(name) {
result.Failures = append(result.Failures, SkillImportFailure{Name: name, Err: errors.New("invalid skill directory name")})
continue
}
if err := validateImportSkill(path, name); err != nil {
result.Failures = append(result.Failures, SkillImportFailure{Name: name, Err: err})
continue
}
state, err := importSkillDirectory(path, filepath.Join(destination, name))
if err != nil {
result.Failures = append(result.Failures, SkillImportFailure{Name: name, Err: err})
continue
}
if state == skillImportExisting {
result.Existing = append(result.Existing, name)
} else {
result.Imported = append(result.Imported, name)
}
}
return result, nil
}
func validateImportSkill(dir, name string) error {
manifest := filepath.Join(dir, skillFilename)
info, err := os.Lstat(manifest)
if err != nil {
return fmt.Errorf("inspect %s: %w", skillFilename, err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return fmt.Errorf("%s must be a regular, non-symlinked file", skillFilename)
}
if _, err := parseSkill(manifest, name); err != nil {
return err
}
return walkImportTree(dir, func(path string, entry fs.DirEntry, info fs.FileInfo) error {
if info.IsDir() || path == dir {
return nil
}
if !info.Mode().IsRegular() {
return fmt.Errorf("only regular files may be imported: %s", path)
}
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
return file.Close()
})
}
func walkImportTree(root string, visit func(string, fs.DirEntry, fs.FileInfo) error) error {
return filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(root, path)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return fmt.Errorf("unsafe skill path %q", path)
}
if entry.Type()&os.ModeSymlink != 0 {
return fmt.Errorf("symlinks may not be imported: %s", path)
}
info, err := entry.Info()
if err != nil {
return err
}
return visit(path, entry, info)
})
}
type skillImportState int
const (
skillImportCopied skillImportState = iota
skillImportExisting
)
func importSkillDirectory(source, destination string) (skillImportState, error) {
if info, err := os.Lstat(destination); err == nil {
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return 0, errors.New("destination exists but is not a regular directory")
}
same, err := sameImportTree(source, destination)
if err != nil {
return 0, fmt.Errorf("inspect existing destination: %w", err)
}
if same {
return skillImportExisting, nil
}
return 0, errors.New("destination skill already exists with different contents")
} else if !errors.Is(err, fs.ErrNotExist) {
return 0, fmt.Errorf("inspect destination: %w", err)
}
if err := ensureImportDestination(filepath.Dir(destination)); err != nil {
return 0, err
}
stage, err := os.MkdirTemp(filepath.Dir(destination), "."+filepath.Base(destination)+".import-")
if err != nil {
return 0, fmt.Errorf("create import staging directory: %w", err)
}
defer os.RemoveAll(stage)
if err := copyImportTree(source, stage); err != nil {
return 0, err
}
if _, err := os.Lstat(destination); err == nil {
return 0, errors.New("destination skill was created during import")
} else if !errors.Is(err, fs.ErrNotExist) {
return 0, fmt.Errorf("inspect destination before install: %w", err)
}
if err := os.Rename(stage, destination); err != nil {
return 0, fmt.Errorf("install imported skill: %w", err)
}
return skillImportCopied, nil
}
func ensureImportDestination(dir string) error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create Ollama skills directory: %w", err)
}
info, err := os.Lstat(dir)
if err != nil {
return fmt.Errorf("inspect Ollama skills directory: %w", err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return errors.New("Ollama skills directory must be a regular, non-symlinked directory")
}
return nil
}
func copyImportTree(source, destination string) error {
return walkImportTree(source, func(path string, entry fs.DirEntry, info fs.FileInfo) error {
rel, err := filepath.Rel(source, path)
if err != nil {
return err
}
target := destination
if rel != "." {
target = filepath.Join(destination, rel)
}
if info.IsDir() {
if rel == "." {
return nil
}
return os.Mkdir(target, info.Mode().Perm())
}
if !info.Mode().IsRegular() {
return fmt.Errorf("only regular files may be imported: %s", path)
}
return copyImportFile(path, target, info.Mode().Perm())
})
}
func copyImportFile(source, destination string, mode fs.FileMode) error {
in, err := os.Open(source)
if err != nil {
return fmt.Errorf("read %s: %w", source, err)
}
defer in.Close()
out, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
if err != nil {
return fmt.Errorf("create %s: %w", destination, err)
}
_, copyErr := io.Copy(out, in)
closeErr := out.Close()
if copyErr != nil {
return fmt.Errorf("copy %s: %w", source, copyErr)
}
if closeErr != nil {
return fmt.Errorf("write %s: %w", destination, closeErr)
}
return nil
}
func sameImportTree(source, destination string) (bool, error) {
seen := make(map[string]struct{})
same := true
err := walkImportTree(source, func(path string, entry fs.DirEntry, info fs.FileInfo) error {
rel, err := filepath.Rel(source, path)
if err != nil {
return err
}
seen[rel] = struct{}{}
other := destination
if rel != "." {
other = filepath.Join(destination, rel)
}
otherInfo, err := os.Lstat(other)
if errors.Is(err, fs.ErrNotExist) {
same = false
return nil
}
if err != nil {
return err
}
if otherInfo.Mode()&os.ModeSymlink != 0 || otherInfo.IsDir() != info.IsDir() || (!info.IsDir() && !otherInfo.Mode().IsRegular()) {
same = false
return nil
}
if info.Mode().IsRegular() {
equal, err := sameImportFile(path, other)
if err != nil {
return err
}
if !equal {
same = false
}
}
return nil
})
if err != nil || !same {
return same, err
}
err = walkImportTree(destination, func(path string, entry fs.DirEntry, info fs.FileInfo) error {
rel, err := filepath.Rel(destination, path)
if err != nil {
return err
}
if _, ok := seen[rel]; !ok {
same = false
}
return nil
})
return same, err
}
func sameImportFile(first, second string) (bool, error) {
a, err := os.Open(first)
if err != nil {
return false, err
}
defer a.Close()
b, err := os.Open(second)
if err != nil {
return false, err
}
defer b.Close()
left := make([]byte, 32*1024)
right := make([]byte, len(left))
for {
n, errA := a.Read(left)
m, errB := b.Read(right)
if n != m || !bytes.Equal(left[:n], right[:m]) {
return false, nil
}
if errA == io.EOF && errB == io.EOF {
return true, nil
}
if errA != nil && errA != io.EOF {
return false, errA
}
if errB != nil && errB != io.EOF {
return false, errB
}
if errA == io.EOF || errB == io.EOF {
return false, nil
}
}
}
// defaultSkillRoots returns skill directories ordered lowest- to
// highest-precedence. Non-existent directories are scanned harmlessly
// (DiscoverSkills skips them).
@@ -325,6 +675,31 @@ func (c *SkillCatalog) Diagnostics() []error {
return append([]error(nil), c.diagnostics...)
}
// ExcludeNames removes skills whose names are reserved by a caller. It returns
// the excluded names in sorted order.
func (c *SkillCatalog) ExcludeNames(names []string) []string {
if c == nil {
return nil
}
reserved := make(map[string]struct{}, len(names))
for _, name := range names {
name = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(name)), "/")
if name != "" {
reserved[name] = struct{}{}
}
}
var excluded []string
for name := range c.skills {
if _, ok := reserved[name]; !ok {
continue
}
delete(c.skills, name)
excluded = append(excluded, name)
}
sort.Strings(excluded)
return excluded
}
func (c *SkillCatalog) Load(name string) (Skill, error) {
name = strings.TrimSpace(name)
if !skillName.MatchString(name) {
+223
View File
@@ -21,6 +21,21 @@ func writeCatalogSkill(t *testing.T, dir, name, content string) {
}
}
func writeImportFixtureSkill(t *testing.T, dir string) {
t.Helper()
contents, err := os.ReadFile(filepath.Join("testdata", "import", "release-notes", skillFilename))
if err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "release-notes", skillFilename)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, contents, 0o644); err != nil {
t.Fatal(err)
}
}
func TestDiscoverAndLoadSkills(t *testing.T) {
dir := t.TempDir()
writeCatalogSkill(t, dir, "release-notes", "---\nname: release-notes\ndescription: Draft concise release notes.\nmetadata:\n author: Ollama\n labels:\n - release\n - docs\n---\n# Release notes\n\nUse short bullets.")
@@ -257,6 +272,30 @@ func TestLoadDefaultSkillsPrecedenceAndCollisions(t *testing.T) {
}
}
func TestSkillCatalogExcludeNames(t *testing.T) {
dir := t.TempDir()
for _, name := range []string{"release-notes", "system", "exit"} {
writeCatalogSkill(t, dir, name, "instructions")
}
catalog, err := DiscoverSkills(dir)
if err != nil {
t.Fatal(err)
}
if got, want := strings.Join(catalog.ExcludeNames([]string{"/system", "EXIT"}), ","), "exit,system"; got != want {
t.Fatalf("excluded skills = %q, want %q", got, want)
}
if _, err := catalog.Load("system"); err == nil {
t.Fatal("excluded system skill should not load")
}
if _, err := catalog.Load("exit"); err == nil {
t.Fatal("excluded exit skill should not load")
}
if _, err := catalog.Load("release-notes"); err != nil {
t.Fatalf("non-conflicting skill should remain available: %v", err)
}
}
func TestSkillContentListsDirectoryAndResources(t *testing.T) {
root := t.TempDir()
skillDir := filepath.Join(root, "pdf-processing")
@@ -291,3 +330,187 @@ func TestSkillContentListsDirectoryAndResources(t *testing.T) {
t.Fatalf("content missing resource listing: %q", content)
}
}
func TestImportSkillsCopiesFixtureAndIsIdempotent(t *testing.T) {
source := t.TempDir()
destination := t.TempDir()
writeImportFixtureSkill(t, source)
writeCatalogSkill(t, source, "broken", "---\nname: another-skill\ndescription: Deliberately invalid.\n---\nIgnore this.")
if err := os.MkdirAll(filepath.Join(source, "release-notes", "references"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(source, "release-notes", "references", "style.txt"), []byte("Keep it short.\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(source, "release-notes", "scripts"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(source, "release-notes", "scripts", "prepare.sh"), []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(source, "ignored.md"), []byte("Ignored root file.\n"), 0o644); err != nil {
t.Fatal(err)
}
result, err := importSkillsFromDir("codex", source, destination)
if err != nil {
t.Fatal(err)
}
if got, want := strings.Join(result.Imported, ","), "release-notes"; got != want {
t.Fatalf("imported = %q, want %q", got, want)
}
catalog, err := DiscoverSkills(destination)
if err != nil {
t.Fatal(err)
}
skill, err := catalog.Load("release-notes")
if err != nil || skill.Description != "Draft concise release notes." {
t.Fatalf("imported skill = %#v, %v", skill, err)
}
if got := len(result.Failures); got != 1 || result.Failures[0].Name != "broken" {
t.Fatalf("failures = %#v, want broken fixture failure", result.Failures)
}
for _, file := range []string{skillFilename, filepath.Join("references", "style.txt"), filepath.Join("scripts", "prepare.sh")} {
if _, err := os.Stat(filepath.Join(destination, "release-notes", file)); err != nil {
t.Fatalf("imported fixture file %q: %v", file, err)
}
}
result, err = importSkillsFromDir("codex", source, destination)
if err != nil {
t.Fatal(err)
}
if got, want := strings.Join(result.Existing, ","), "release-notes"; got != want {
t.Fatalf("existing = %q, want %q", got, want)
}
if len(result.Imported) != 0 {
t.Fatalf("repeated import copied skills: %#v", result.Imported)
}
}
func TestImportSkillsLeavesConflictsAndUnsafeSourcesUntouched(t *testing.T) {
source := t.TempDir()
destination := t.TempDir()
writeCatalogSkill(t, source, "release-notes", "source instructions")
writeCatalogSkill(t, destination, "release-notes", "existing instructions")
writeCatalogSkill(t, source, "nested-link", "safe manifest")
if err := os.Symlink(filepath.Join(source, "release-notes", skillFilename), filepath.Join(source, "nested-link", "reference")); err != nil {
t.Skipf("symlink not supported: %v", err)
}
if err := os.Symlink(filepath.Join(source, "release-notes"), filepath.Join(source, "linked-skill")); err != nil {
t.Skipf("symlink not supported: %v", err)
}
result, err := importSkillsFromDir("codex", source, destination)
if err != nil {
t.Fatal(err)
}
if len(result.Imported) != 0 || len(result.Existing) != 0 {
t.Fatalf("unexpected successful import: %#v", result)
}
if got, err := os.ReadFile(filepath.Join(destination, "release-notes", skillFilename)); err != nil || !strings.Contains(string(got), "existing instructions") {
t.Fatalf("conflicting destination changed: %q, %v", got, err)
}
failed := make(map[string]bool)
for _, failure := range result.Failures {
failed[failure.Name] = true
}
for _, name := range []string{"release-notes", "nested-link", "linked-skill"} {
if !failed[name] {
t.Fatalf("missing failure for %q: %#v", name, result.Failures)
}
}
}
func TestImportSkillsRejectsSymlinkedRoot(t *testing.T) {
root := t.TempDir()
source := filepath.Join(t.TempDir(), "codex-skills")
if err := os.Symlink(root, source); err != nil {
t.Skipf("symlink not supported: %v", err)
}
result, err := importSkillsFromDir("codex", source, t.TempDir())
if err == nil || !strings.Contains(err.Error(), "symlinks are not supported") {
t.Fatalf("symlinked root error = %v", err)
}
if len(result.Imported) != 0 || len(result.Existing) != 0 || len(result.Failures) != 0 {
t.Fatalf("symlinked root result = %#v", result)
}
}
func TestImportSkillsMissingRootAndConfiguredRoots(t *testing.T) {
result, err := importSkillsFromDir("codex", filepath.Join(t.TempDir(), "missing"), t.TempDir())
if err != nil {
t.Fatal(err)
}
if len(result.Imported) != 0 || len(result.Existing) != 0 || len(result.Failures) != 0 {
t.Fatalf("missing root result = %#v", result)
}
destination := t.TempDir()
rootBase := t.TempDir()
roots := map[string]string{
"codex": filepath.Join(rootBase, "codex"),
"claude": filepath.Join(rootBase, "claude"),
"pi": filepath.Join(rootBase, "pi"),
}
for _, test := range []struct {
source string
root string
name string
}{
{source: "codex", root: roots["codex"], name: "from-codex"},
{source: "claude", root: roots["claude"], name: "from-claude"},
{source: "pi", root: roots["pi"], name: "from-pi"},
} {
t.Run(test.source, func(t *testing.T) {
writeCatalogSkill(t, test.root, test.name, "from "+test.source)
result, err = importSkillsFromRoots(test.source, roots, destination)
if err != nil {
t.Fatal(err)
}
if result.SourceDir != test.root {
t.Fatalf("source dir = %q, want %q", result.SourceDir, test.root)
}
if _, err := os.Stat(filepath.Join(destination, test.name, skillFilename)); err != nil {
t.Fatalf("conventional source was not imported: %v", err)
}
})
}
if _, err := importSkillsFromRoots("unknown", roots, destination); err == nil || !strings.Contains(err.Error(), "unknown skill source") {
t.Fatalf("unknown source error = %v", err)
}
}
func TestConventionalSkillImportRoots(t *testing.T) {
home := t.TempDir()
roots := conventionalSkillImportRoots(home)
for source, want := range map[string]string{
"codex": filepath.Join(home, ".codex", "skills"),
"claude": filepath.Join(home, ".claude", "skills"),
"pi": filepath.Join(home, ".pi", "agent", "skills"),
} {
if got := roots[source]; got != want {
t.Fatalf("%s root = %q, want %q", source, got, want)
}
}
}
func TestImportSkillsRejectsUnreadableManifest(t *testing.T) {
source := t.TempDir()
writeCatalogSkill(t, source, "private", "do not read")
manifest := filepath.Join(source, "private", skillFilename)
if err := os.Chmod(manifest, 0); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(manifest, 0o644) })
if _, err := os.ReadFile(manifest); err == nil {
t.Skip("test user can read a mode-000 file")
}
result, err := importSkillsFromDir("codex", source, t.TempDir())
if err != nil {
t.Fatal(err)
}
if len(result.Failures) != 1 || result.Failures[0].Name != "private" {
t.Fatalf("failures = %#v", result.Failures)
}
}
+8
View File
@@ -0,0 +1,8 @@
---
name: release-notes
description: Draft concise release notes.
---
# Release notes
Use short bullets.
+183 -30
View File
@@ -2,11 +2,14 @@ package tools
import (
"bufio"
"cmp"
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
@@ -105,26 +108,37 @@ func (e *Edit) Name() string {
}
func (e *Edit) Description() string {
return "Edit a text file in the current working directory by replacing exact text."
return "Edit a text file in the current working directory by replacing exact text. Pass multiple edits to change separate parts of the file in one call."
}
func (e *Edit) Schema() api.ToolFunction {
editProps := api.NewToolPropertiesMap()
editProps.Set("old_text", api.ToolProperty{
Type: api.PropertyType{"string"},
Description: "Exact text for one targeted replacement. Must match the original file exactly once and must not overlap with any other edit's old_text.",
})
editProps.Set("new_text", api.ToolProperty{
Type: api.PropertyType{"string"},
Description: "Replacement text for this targeted edit.",
})
props := api.NewToolPropertiesMap()
props.Set("path", api.ToolProperty{
Type: api.PropertyType{"string"},
Description: "Path to the file to edit, relative to the working directory.",
})
props.Set("old_text", api.ToolProperty{
Type: api.PropertyType{"string"},
Description: "Exact text to replace.",
})
props.Set("new_text", api.ToolProperty{
Type: api.PropertyType{"string"},
Description: "Replacement text.",
props.Set("edits", api.ToolProperty{
Type: api.PropertyType{"array"},
Items: api.ToolProperty{
Type: api.PropertyType{"object"},
Properties: editProps,
Required: []string{"old_text", "new_text"},
},
Description: "One or more exact-text replacements. Each is matched against the original file, not against the output of earlier edits. Keep old_text as small as possible while still unique in the file; merge changes to the same or adjacent lines into a single edit.",
})
props.Set("replace_all", api.ToolProperty{
Type: api.PropertyType{"boolean"},
Description: "Replace every occurrence. Defaults to false and requires old_text to match exactly once.",
Description: "Replace every occurrence. Defaults to false; only applies when a single edit is provided.",
})
return api.ToolFunction{
Name: e.Name(),
@@ -132,7 +146,7 @@ func (e *Edit) Schema() api.ToolFunction {
Parameters: api.ToolFunctionParameters{
Type: "object",
Properties: props,
Required: []string{"path", "old_text", "new_text"},
Required: []string{"path", "edits"},
},
}
}
@@ -148,18 +162,11 @@ func (e *Edit) Execute(ctx context.Context, toolCtx agent.ToolContext, args map[
return agent.ToolResult{}, fmt.Errorf("path parameter is required")
}
oldText, ok := args["old_text"].(string)
if !ok || oldText == "" {
return agent.ToolResult{}, fmt.Errorf("old_text parameter is required")
edits, replaceAll, err := parseEditArgs(args)
if err != nil {
return agent.ToolResult{}, err
}
newText, ok := args["new_text"].(string)
if !ok {
return agent.ToolResult{}, fmt.Errorf("new_text parameter is required")
}
replaceAll, _ := args["replace_all"].(bool)
if err := rejectFinalSymlink(toolCtx.WorkingDir, path); err != nil {
return agent.ToolResult{}, err
}
@@ -188,19 +195,56 @@ func (e *Edit) Execute(ctx context.Context, toolCtx agent.ToolContext, args map[
return agent.ToolResult{}, err
}
content := string(contentBytes)
matches := strings.Count(content, oldText)
if matches == 0 {
return agent.ToolResult{}, fmt.Errorf("old_text was not found in %s", path)
}
if matches > 1 && !replaceAll {
return agent.ToolResult{}, fmt.Errorf("old_text matched %d times in %s; set replace_all to true to replace every match", matches, path)
}
var updated string
replacements := 0
if replaceAll {
updated = strings.ReplaceAll(content, oldText, newText)
matches := strings.Count(content, edits[0].OldText)
if matches == 0 {
return agent.ToolResult{}, fmt.Errorf("old_text was not found in %s", path)
}
updated = strings.ReplaceAll(content, edits[0].OldText, edits[0].NewText)
replacements = matches
} else {
updated = strings.Replace(content, oldText, newText, 1)
// Every edit is matched against the original file content rather
// than the output of earlier edits, so each edit must match exactly
// once and edits must target disjoint regions.
matched := make([]editMatch, 0, len(edits))
for i, edit := range edits {
count := strings.Count(content, edit.OldText)
if count == 0 {
return agent.ToolResult{}, editNotFoundError(path, i, len(edits))
}
if count > 1 {
return agent.ToolResult{}, editAmbiguousError(path, i, len(edits), count)
}
matched = append(matched, editMatch{
editIndex: i,
offset: strings.Index(content, edit.OldText),
length: len(edit.OldText),
newText: edit.NewText,
})
replacements++
}
slices.SortFunc(matched, func(a, b editMatch) int { return cmp.Compare(a.offset, b.offset) })
for i := 1; i < len(matched); i++ {
prev, cur := matched[i-1], matched[i]
if prev.offset+prev.length > cur.offset {
return agent.ToolResult{}, fmt.Errorf("edits[%d] and edits[%d] overlap in %s; merge them into one edit or target disjoint text", prev.editIndex, cur.editIndex, path)
}
}
// Apply from the end of the file backwards so earlier offsets stay valid.
updated = content
for i := len(matched) - 1; i >= 0; i-- {
m := matched[i]
updated = updated[:m.offset] + m.newText + updated[m.offset+m.length:]
}
}
if updated == content {
return agent.ToolResult{}, fmt.Errorf("edit produced no changes in %s; replacement text is identical to the original", path)
}
if len(updated) > maxReadBytes {
return agent.ToolResult{}, fmt.Errorf("edited content is too large (%d bytes)", len(updated))
@@ -210,7 +254,116 @@ func (e *Edit) Execute(ctx context.Context, toolCtx agent.ToolContext, args map[
return agent.ToolResult{}, err
}
return agent.ToolResult{Content: fmt.Sprintf("Updated %s (%d replacement%s).", path, matches, plural(matches))}, nil
return agent.ToolResult{Content: fmt.Sprintf("Updated %s (%d edit%s, %d replacement%s).", path, len(edits), plural(len(edits)), replacements, plural(replacements))}, nil
}
// editReplacement is one targeted replacement within an edit call.
type editReplacement struct {
OldText string
NewText string
}
// editMatch locates one editReplacement within the original file content.
type editMatch struct {
editIndex int
offset int
length int
newText string
}
// parseEditArgs normalizes edit arguments from a tool call into a list of
// replacements. It accepts the `edits` array form and tolerates legacy
// top-level old_text/new_text args as well as stringified JSON, mirroring
// the pi coding agent's argument handling.
func parseEditArgs(args map[string]any) ([]editReplacement, bool, error) {
replaceAll, _ := args["replace_all"].(bool)
var edits []editReplacement
if raw, ok := args["edits"]; ok {
parsed, err := parseEditArray(raw)
if err != nil {
return nil, false, err
}
edits = parsed
}
// Fold a legacy top-level old_text/new_text pair into edits.
if oldText, ok := args["old_text"].(string); ok {
newText, ok := args["new_text"].(string)
if !ok {
return nil, false, fmt.Errorf("new_text parameter is required")
}
edits = append(edits, editReplacement{OldText: oldText, NewText: newText})
}
if len(edits) == 0 {
return nil, false, fmt.Errorf("edits parameter is required")
}
for i, edit := range edits {
if edit.OldText == "" {
if len(edits) == 1 {
return nil, false, fmt.Errorf("old_text parameter is required")
}
return nil, false, fmt.Errorf("edits[%d].old_text must not be empty", i)
}
}
if replaceAll && len(edits) != 1 {
return nil, false, fmt.Errorf("replace_all only applies to a single edit")
}
return edits, replaceAll, nil
}
func parseEditArray(raw any) ([]editReplacement, error) {
if s, ok := raw.(string); ok {
// Some models serialize array arguments as a JSON string.
if err := json.Unmarshal([]byte(s), &raw); err != nil {
return nil, fmt.Errorf("edits must be an array of {old_text, new_text} objects")
}
}
items, ok := raw.([]any)
if !ok {
return nil, fmt.Errorf("edits must be an array of {old_text, new_text} objects")
}
edits := make([]editReplacement, 0, len(items))
for i, item := range items {
entry, ok := item.(map[string]any)
if !ok {
return nil, fmt.Errorf("edits[%d] must be an object with old_text and new_text", i)
}
oldText, oldOK := editTextArg(entry, "old_text", "oldText")
newText, newOK := editTextArg(entry, "new_text", "newText")
if !oldOK || !newOK {
return nil, fmt.Errorf("edits[%d] must be an object with old_text and new_text", i)
}
edits = append(edits, editReplacement{OldText: oldText, NewText: newText})
}
return edits, nil
}
// editTextArg reads the first present string key, tolerating both snake_case
// and camelCase spellings that models emit.
func editTextArg(entry map[string]any, keys ...string) (string, bool) {
for _, key := range keys {
if value, ok := entry[key].(string); ok {
return value, true
}
}
return "", false
}
func editNotFoundError(path string, editIndex, totalEdits int) error {
if totalEdits == 1 {
return fmt.Errorf("old_text was not found in %s", path)
}
return fmt.Errorf("edits[%d].old_text was not found in %s", editIndex, path)
}
func editAmbiguousError(path string, editIndex, totalEdits, occurrences int) error {
if totalEdits == 1 {
return fmt.Errorf("old_text matched %d times in %s; set replace_all to true to replace every match", occurrences, path)
}
return fmt.Errorf("edits[%d].old_text matched %d times in %s; each edit must match exactly once, so provide more surrounding context", editIndex, occurrences, path)
}
func cleanRelativePath(path string) (string, error) {
+233
View File
@@ -59,6 +59,239 @@ func TestEditRequiresUniqueMatchByDefault(t *testing.T) {
}
}
func TestEditAppliesMultipleEdits(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "note.txt")
if err := os.WriteFile(path, []byte("alpha beta gamma delta\n"), 0o644); err != nil {
t.Fatal(err)
}
result, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
"path": "note.txt",
"edits": []any{
map[string]any{"old_text": "beta", "new_text": "BETA"},
map[string]any{"old_text": "delta", "new_text": "DELTA"},
},
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(result.Content, "2 edits, 2 replacements") {
t.Fatalf("result = %q", result.Content)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(content) != "alpha BETA gamma DELTA\n" {
t.Fatalf("content = %q", content)
}
}
func TestEditMatchesEditsAgainstOriginalContent(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "note.txt")
if err := os.WriteFile(path, []byte("abc def\n"), 0o644); err != nil {
t.Fatal(err)
}
// edits[1] must target the original "def", not the one introduced by edits[0].
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
"path": "note.txt",
"edits": []any{
map[string]any{"old_text": "abc", "new_text": "def"},
map[string]any{"old_text": "def", "new_text": "ghi"},
},
})
if err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(content) != "def ghi\n" {
t.Fatalf("content = %q", content)
}
}
func TestEditRejectsOverlappingEdits(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "note.txt")
if err := os.WriteFile(path, []byte("abc\n"), 0o644); err != nil {
t.Fatal(err)
}
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
"path": "note.txt",
"edits": []any{
map[string]any{"old_text": "ab", "new_text": "x"},
map[string]any{"old_text": "bc", "new_text": "y"},
},
})
if err == nil {
t.Fatal("expected overlapping edits to fail")
}
if !strings.Contains(err.Error(), "overlap") {
t.Fatalf("err = %v", err)
}
}
func TestEditMultipleEditsNotFoundIndexed(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "note.txt")
if err := os.WriteFile(path, []byte("hello world\n"), 0o644); err != nil {
t.Fatal(err)
}
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
"path": "note.txt",
"edits": []any{
map[string]any{"old_text": "hello", "new_text": "hi"},
map[string]any{"old_text": "missing", "new_text": "x"},
},
})
if err == nil {
t.Fatal("expected missing edit to fail")
}
if !strings.Contains(err.Error(), "edits[1]") {
t.Fatalf("err = %v", err)
}
}
func TestEditMultipleEditsAmbiguousIndexed(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "note.txt")
if err := os.WriteFile(path, []byte("hello same same\n"), 0o644); err != nil {
t.Fatal(err)
}
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
"path": "note.txt",
"edits": []any{
map[string]any{"old_text": "hello", "new_text": "hi"},
map[string]any{"old_text": "same", "new_text": "x"},
},
})
if err == nil {
t.Fatal("expected ambiguous edit to fail")
}
if !strings.Contains(err.Error(), "edits[1]") || !strings.Contains(err.Error(), "matched 2 times") {
t.Fatalf("err = %v", err)
}
}
func TestEditRejectsEmptyEdits(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "note.txt")
if err := os.WriteFile(path, []byte("hello\n"), 0o644); err != nil {
t.Fatal(err)
}
for name, args := range map[string]map[string]any{
"missing edits": {"path": "note.txt"},
"empty edits": {"path": "note.txt", "edits": []any{}},
} {
if _, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, args); err == nil {
t.Fatalf("%s: expected error", name)
} else if !strings.Contains(err.Error(), "edits parameter is required") {
t.Fatalf("%s: err = %v", name, err)
}
}
}
func TestEditRejectsEmptyOldTextInArray(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "note.txt")
if err := os.WriteFile(path, []byte("hello\n"), 0o644); err != nil {
t.Fatal(err)
}
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
"path": "note.txt",
"edits": []any{
map[string]any{"old_text": "hello", "new_text": "hi"},
map[string]any{"old_text": "", "new_text": "x"},
},
})
if err == nil {
t.Fatal("expected empty old_text to fail")
}
if !strings.Contains(err.Error(), "edits[1].old_text must not be empty") {
t.Fatalf("err = %v", err)
}
}
func TestEditAcceptsJSONStringEdits(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "note.txt")
if err := os.WriteFile(path, []byte("hello world\n"), 0o644); err != nil {
t.Fatal(err)
}
// Some models serialize array arguments as a JSON string.
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
"path": "note.txt",
"edits": `[{"oldText": "hello", "newText": "hi"}, {"oldText": "world", "newText": "earth"}]`,
})
if err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(content) != "hi earth\n" {
t.Fatalf("content = %q", content)
}
}
func TestEditRejectsReplaceAllWithMultipleEdits(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "note.txt")
if err := os.WriteFile(path, []byte("a b c\n"), 0o644); err != nil {
t.Fatal(err)
}
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
"path": "note.txt",
"replace_all": true,
"edits": []any{
map[string]any{"old_text": "a", "new_text": "x"},
map[string]any{"old_text": "b", "new_text": "y"},
},
})
if err == nil {
t.Fatal("expected replace_all with multiple edits to fail")
}
if !strings.Contains(err.Error(), "replace_all") {
t.Fatalf("err = %v", err)
}
}
func TestEditRejectsNoChange(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "note.txt")
if err := os.WriteFile(path, []byte("hello\n"), 0o644); err != nil {
t.Fatal(err)
}
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
"path": "note.txt",
"old_text": "hello",
"new_text": "hello",
})
if err == nil {
t.Fatal("expected no-change edit to fail")
}
if !strings.Contains(err.Error(), "no changes") {
t.Fatalf("err = %v", err)
}
}
func TestEditRejectsEscapingPath(t *testing.T) {
dir := t.TempDir()
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
+5 -2
View File
@@ -9,8 +9,9 @@ import (
)
// Skill is the model-facing adapter for the core agent skill catalog.
// It only supplies instructions; regular tools retain their own approval
// requirements for filesystem or network access.
// Model-initiated loads require approval because a skill's instructions can
// influence the rest of the run. Explicit user activation is handled by the
// session's synthetic skill call and bypasses this adapter.
type Skill struct{ Catalog *agent.SkillCatalog }
func (t *Skill) Name() string { return "skill" }
@@ -25,6 +26,8 @@ func (t *Skill) Schema() api.ToolFunction {
return api.ToolFunction{Name: t.Name(), Description: t.Description(), Parameters: api.ToolFunctionParameters{Type: "object", Properties: props, Required: []string{"name"}}}
}
func (t *Skill) RequiresApproval(map[string]any) bool { return true }
func (t *Skill) Execute(_ context.Context, _ agent.ToolContext, args map[string]any) (agent.ToolResult, error) {
name, ok := args["name"].(string)
if !ok {
+138 -9
View File
@@ -8,9 +8,117 @@ import (
"testing"
"github.com/ollama/ollama/agent"
"github.com/ollama/ollama/api"
)
func TestSkillLoadsCoreCatalogWithoutApproval(t *testing.T) {
func TestSkillLoadsCoreCatalogWithApproval(t *testing.T) {
catalog := testSkillCatalog(t)
tool := &Skill{Catalog: catalog}
if !agent.ToolRequiresApproval(tool, map[string]any{"name": "release-notes"}) {
t.Fatal("model-initiated skill loading should require approval")
}
result, err := tool.Execute(context.Background(), agent.ToolContext{}, map[string]any{"name": "release-notes"})
if err != nil || !strings.Contains(result.Content, "Use concise bullets.") {
t.Fatalf("tool result = %#v, %v", result, err)
}
}
func TestModelSkillLoadRequiresApproval(t *testing.T) {
for _, tt := range []struct {
name string
approval agent.Approval
prompt bool
wantCalls int
wantPrompts int
wantResult string
}{
{name: "rejected", approval: agent.Approval{Reason: "Skill loading denied."}, prompt: true, wantCalls: 1, wantPrompts: 1, wantResult: "Skill loading denied."},
{name: "approved", approval: agent.Approval{Allow: true}, prompt: true, wantCalls: 2, wantPrompts: 1, wantResult: "Use concise bullets."},
{name: "headless denied", wantCalls: 1, wantResult: "Tool execution requires approval"},
} {
t.Run(tt.name, func(t *testing.T) {
catalog := testSkillCatalog(t)
args := api.NewToolCallFunctionArguments()
args.Set("name", "release-notes")
client := &skillTestClient{responses: [][]api.ChatResponse{
{{Message: api.Message{Role: "assistant", ToolCalls: []api.ToolCall{{
ID: "call_skill_1",
Function: api.ToolCallFunction{Name: "skill", Arguments: args},
}}}}},
{{Message: api.Message{Role: "assistant", Content: "done"}}},
}}
var prompter *skillApprovalPrompter
var approvalPrompter agent.ApprovalPrompter
if tt.prompt {
prompter = &skillApprovalPrompter{result: tt.approval}
approvalPrompter = prompter
}
registry := &agent.Registry{}
registry.Register(&Skill{Catalog: catalog})
result, err := (&agent.Session{
Client: client,
Tools: registry,
ApprovalPrompter: approvalPrompter,
}).Run(context.Background(), agent.RunOptions{
Model: "test",
NewMessages: []api.Message{{Role: "user", Content: "load the release-notes skill"}},
})
if err != nil {
t.Fatal(err)
}
if tt.prompt {
if got := len(prompter.requests); got != tt.wantPrompts {
t.Fatalf("approval prompts = %d, want %d", got, tt.wantPrompts)
}
request := prompter.requests[0]
if len(request.Calls) != 1 || request.Calls[0].ToolName != "skill" || request.Calls[0].ApprovalScope != "skill" || request.Calls[0].Args["name"] != "release-notes" {
t.Fatalf("approval request = %#v", request)
}
}
if got := client.calls; got != tt.wantCalls {
t.Fatalf("model calls = %d, want %d", got, tt.wantCalls)
}
var toolResult string
for _, message := range result.Messages {
if message.Role == "tool" && message.ToolCallID == "call_skill_1" {
toolResult = message.Content
break
}
}
if !strings.Contains(toolResult, tt.wantResult) {
t.Fatalf("skill tool result = %q, want it to contain %q", toolResult, tt.wantResult)
}
})
}
}
func TestExplicitSkillActivationBypassesApproval(t *testing.T) {
catalog := testSkillCatalog(t)
client := &skillTestClient{responses: [][]api.ChatResponse{{{Message: api.Message{Role: "assistant", Content: "done"}}}}}
prompter := &skillApprovalPrompter{result: agent.Approval{}}
result, err := (&agent.Session{
Client: client,
Skills: catalog,
ApprovalPrompter: prompter,
}).Run(context.Background(), agent.RunOptions{
Model: "test",
NewMessages: []api.Message{{Role: "user", Content: "draft release notes"}},
SkillName: "release-notes",
})
if err != nil {
t.Fatal(err)
}
if len(prompter.requests) != 0 {
t.Fatalf("explicit activation prompted for approval: %#v", prompter.requests)
}
if len(result.Messages) != 4 || result.Messages[2].ToolName != "skill" || !strings.Contains(result.Messages[2].Content, "Use concise bullets.") {
t.Fatalf("synthetic skill activation = %#v", result.Messages)
}
}
func testSkillCatalog(t *testing.T) *agent.SkillCatalog {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "release-notes")
if err := os.Mkdir(path, 0o755); err != nil {
@@ -23,12 +131,33 @@ func TestSkillLoadsCoreCatalogWithoutApproval(t *testing.T) {
if err != nil {
t.Fatal(err)
}
tool := &Skill{Catalog: catalog}
if agent.ToolRequiresApproval(tool, map[string]any{"name": "release-notes"}) {
t.Fatal("loading a skill must not change ordinary tool approval semantics")
}
result, err := tool.Execute(context.Background(), agent.ToolContext{}, map[string]any{"name": "release-notes"})
if err != nil || !strings.Contains(result.Content, "Use concise bullets.") {
t.Fatalf("tool result = %#v, %v", result, err)
}
return catalog
}
type skillTestClient struct {
responses [][]api.ChatResponse
calls int
}
func (c *skillTestClient) Chat(_ context.Context, _ *api.ChatRequest, fn api.ChatResponseFunc) error {
if c.calls >= len(c.responses) {
return nil
}
for _, response := range c.responses[c.calls] {
if err := fn(response); err != nil {
return err
}
}
c.calls++
return nil
}
type skillApprovalPrompter struct {
requests []agent.ApprovalRequest
result agent.Approval
}
func (p *skillApprovalPrompter) PromptApproval(_ context.Context, request agent.ApprovalRequest) (agent.Approval, error) {
p.requests = append(p.requests, request)
return p.result, nil
}
+59
View File
@@ -2,6 +2,7 @@ package api
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -388,6 +389,64 @@ func TestClientWebSearchExperimentalUsesLocalRoute(t *testing.T) {
}
}
func TestClientWebSearchExperimentalErrors(t *testing.T) {
tests := []struct {
name string
status int
body string
assertError func(*testing.T, error)
}{
{
name: "unauthorized retains sign in URL",
status: http.StatusUnauthorized,
body: `{"error":"unauthorized","signin_url":"https://ollama.com/signin/example"}`,
assertError: func(t *testing.T, err error) {
t.Helper()
var authErr AuthorizationError
if !errors.As(err, &authErr) {
t.Fatalf("error = %T, want AuthorizationError", err)
}
if authErr.StatusCode != http.StatusUnauthorized || authErr.SigninURL != "https://ollama.com/signin/example" {
t.Fatalf("authorization error = %#v", authErr)
}
},
},
{
name: "rate limit retains status",
status: http.StatusTooManyRequests,
body: `{"error":"rate limit exceeded"}`,
assertError: func(t *testing.T, err error) {
t.Helper()
var statusErr StatusError
if !errors.As(err, &statusErr) {
t.Fatalf("error = %T, want StatusError", err)
}
if statusErr.StatusCode != http.StatusTooManyRequests || statusErr.ErrorMessage != "rate limit exceeded" {
t.Fatalf("status error = %#v", statusErr)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(tt.status)
_, _ = w.Write([]byte(tt.body))
}))
defer ts.Close()
client := NewClient(&url.URL{Scheme: "http", Host: ts.Listener.Addr().String()}, http.DefaultClient)
_, err := client.WebSearchExperimental(t.Context(), &WebSearchRequest{Query: "ollama"})
if err == nil {
t.Fatal("expected error")
}
tt.assertError(t, err)
})
}
}
func TestClientWebFetchExperimentalUsesLocalRoute(t *testing.T) {
var gotPath string
var gotMethod string
+5 -30
View File
@@ -127,20 +127,6 @@ type GenerateRequest struct {
// each with an associated log probability. Only applies when Logprobs is true.
// Valid values are 0-20. Default is 0 (only return the selected token's logprob).
TopLogprobs int `json:"top_logprobs,omitempty"`
// Experimental: Image generation fields (may change or be removed)
// Width is the width of the generated image in pixels.
// Only used for image generation models.
Width int32 `json:"width,omitempty"`
// Height is the height of the generated image in pixels.
// Only used for image generation models.
Height int32 `json:"height,omitempty"`
// Steps is the number of diffusion steps for image generation.
// Only used for image generation models.
Steps int32 `json:"steps,omitempty"`
}
// ChatRequest describes a request sent by [Client.Chat].
@@ -706,8 +692,11 @@ type CreateRequest struct {
// Messages is a list of messages added to the model before chat and generation requests.
Messages []Message `json:"messages,omitempty"`
// Renderer is the name of the renderer used when constructing a request to the model.
Renderer string `json:"renderer,omitempty"`
Parser string `json:"parser,omitempty"`
// Parser is the name of the parser used to parse the output of the request.
Parser string `json:"parser,omitempty"`
// Requires is the minimum version of Ollama required by the model.
Requires string `json:"requires,omitempty"`
@@ -938,20 +927,6 @@ type GenerateResponse struct {
// Logprobs contains log probability information for the generated tokens,
// if requested via the Logprobs parameter.
Logprobs []Logprob `json:"logprobs,omitempty"`
// Experimental: Image generation fields (may change or be removed)
// Image contains a base64-encoded generated image.
// Only present for image generation models.
Image string `json:"image,omitempty"`
// Completed is the number of completed steps in image generation.
// Only present for image generation models during streaming.
Completed int64 `json:"completed,omitempty"`
// Total is the total number of steps for image generation.
// Only present for image generation models during streaming.
Total int64 `json:"total,omitempty"`
}
// ModelDetails provides details about a model.
@@ -1130,7 +1105,7 @@ func DefaultOptions() Options {
TopP: 0.9,
TypicalP: 1.0,
RepeatLastN: 64,
RepeatPenalty: 1.1,
RepeatPenalty: 1.0,
PresencePenalty: 0.0,
FrequencyPenalty: 0.0,
Seed: -1,
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50" fill="none">
<style>@media (prefers-color-scheme: dark) { path { fill: #fff; } }</style>
<path d="M48.8354 10.0479C48.3232 9.79199 48.1025 10.2798 47.8032 10.5278C46.7793 11.624 45.9048 12.1597 44.7622 12.0957C43.0923 12 41.666 12.5356 40.4058 13.8398C40.1377 12.2319 39.2476 11.272 37.8926 10.6558C36.4668 10.0156 35.9702 9.31982 35.356 7.72754C35.2456 7.3999 35.1353 7.06396 34.7651 7.00781C34.3633 6.94385 34.2056 7.2876 34.0479 7.57568C33.418 8.75195 33.1733 10.0479 33.1973 11.3599C33.2524 14.312 34.4736 16.6641 36.8999 18.3359C37.1758 18.5278 37.2466 18.7197 37.1597 19C36.9946 19.5757 36.7974 20.1357 36.624 20.7119C36.5137 21.0801 36.3486 21.1597 35.9624 21C32.4092 19.4878 30.0381 16.2319 27.2334 13.52C26.7764 13.1758 26.3193 12.856 25.8467 12.5518C23.8618 10.584 26.1069 8.96777 26.627 8.77588C27.1704 8.57568 26.8159 7.8877 25.0591 7.896C22.8691 7.90381 20.4507 9.06396 18.7095 9.58398C16.8501 9.22363 14.9199 9.14355 12.9033 9.37598C5.30859 10.2397 1.15674 16.4717 1.30664 27.2559C2.11768 31.9521 4.46582 35.8398 8.07373 38.8799C11.8159 42.0322 16.1255 43.5762 21.041 43.2803C24.0269 43.104 27.3516 42.6963 31.1016 39.4561C33.0396 40.1279 37.1758 40.208 38.1211 40.0078C39.6021 39.688 39.4995 38.2881 38.9639 38.0322C34.623 35.9678 35.5762 36.8081 34.71 36.1279C36.9155 33.4639 40.2402 30.6958 41.54 21.728C41.6426 21.0161 41.5557 20.5679 41.54 19.9917C41.5322 19.6396 41.6108 19.5039 42.0049 19.4639C46.6924 18.9116 49.064 15.9038 49.3315 11.2559C49.3711 10.7837 49.3237 10.2959 48.8354 10.0479ZM24.3262 37.8398C20.1196 34.4639 18.0791 33.3521 17.2358 33.3999C16.4482 33.4482 16.5898 34.3682 16.7632 34.9678C16.9443 35.5601 17.1812 35.9683 17.5117 36.4878C17.7402 36.832 17.8979 37.3442 17.2832 37.728C15.9282 38.584 13.5728 37.4399 13.4624 37.3838C7.97949 34.0879 4.48926 28.9282 4.19775 21.3677C4.1582 20.5757 4.38672 20.2959 5.15869 20.1519C11.8945 18.8799 17.165 22.0879 19.2529 25.7759C23.5381 30.104 25.335 35.1523 30.479 39.104C28.8643 39.2881 26.1699 39.3281 24.3262 37.8398ZM26.3433 24.6001C26.3433 24.248 26.6191 23.9678 26.9658 23.9678C27.3042 23.9678 27.5801 24.248 27.5801 24.6001C27.5801 24.9521 27.3042 25.2319 26.9575 25.2319C26.6108 25.2319 26.3433 24.9521 26.3433 24.6001ZM32.6064 27.8799C31.6372 28.2881 30.6289 28.3042 29.8096 27.688C28.6987 26.8555 28.6279 25.7759 28.7305 24.9199C28.8721 24.248 28.7144 23.8159 28.2495 23.4238C27.8716 23.104 27.3911 23.0161 26.8633 23.0161C26.666 23.0161 26.4849 22.9277 26.3511 22.856C25.8467 22.5762 25.9805 22.1758 26.5088 21.688C28.0996 20.7598 29.6362 21.9917 30.834 23.3281C31.6216 24.2559 32.8901 26.312 33.1104 26.9521C33.2446 27.3521 33.0713 27.6802 32.6064 27.8799Z" fill="#000"/>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -77,6 +77,14 @@ const LAUNCH_COMMANDS: LaunchCommand[] = [
description: "Factory's coding agent across terminal and IDEs",
icon: "/launch-icons/droid.svg",
},
{
id: "dsh",
name: "DeepSeek Harness",
command: "ollama launch dsh",
description: "DeepSeek's open-source agent harness",
icon: "/launch-icons/deepseek-harness.svg",
iconClassName: "h-7 w-7",
},
{
id: "pi",
name: "Pi",
+9 -3
View File
@@ -153,10 +153,12 @@ func (u *Updater) DownloadNewRelease(ctx context.Context, updateResp UpdateRespo
return err
}
// In case of slow downloads, continue the update check in the background
// In case of slow downloads, continue the update check in the background.
// Drain the goroutine before returning: it reads package-level knobs
// (e.g. UpdateCheckInterval), which callers may mutate once we return.
bgctx, bgcancel := context.WithCancel(downloadCtx)
defer bgcancel()
go func() {
var bgwg sync.WaitGroup
bgwg.Go(func() {
for {
select {
case <-bgctx.Done():
@@ -165,6 +167,10 @@ func (u *Updater) DownloadNewRelease(ctx context.Context, updateResp UpdateRespo
u.checkForUpdate(bgctx)
}
}
})
defer func() {
bgcancel()
bgwg.Wait()
}()
resp, err := http.DefaultClient.Do(req)
+28
View File
@@ -190,6 +190,23 @@ func TestDownloadNewReleaseDoesNotUseRawETagAsPathComponent(t *testing.T) {
}
}
// waitDownloadIdle blocks until no download is in flight, so staged-file
// handles close before t.TempDir cleanup removes the stage directory. After
// the context is cancelled a new download can't write (it aborts at the HEAD
// request), so reaching idle makes cleanup race-free.
func (u *Updater) waitDownloadIdle() {
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
u.cancelDownloadLock.Lock()
idle := u.cancelDownload == nil
u.cancelDownloadLock.Unlock()
if idle {
return
}
time.Sleep(time.Millisecond)
}
}
func TestBackgroundCheckerSkipsAlreadyStagedETagDownload(t *testing.T) {
UpdateStageDir = t.TempDir()
oldInstaller := Installer
@@ -276,6 +293,7 @@ func TestBackgroundCheckerSkipsAlreadyStagedETagDownload(t *testing.T) {
callbacks <- ver
return nil
})
t.Cleanup(updater.waitDownloadIdle)
for range 2 {
select {
@@ -364,6 +382,7 @@ func TestBackgoundChecker(t *testing.T) {
}
updater.StartBackgroundUpdaterChecker(ctx, cb)
t.Cleanup(updater.waitDownloadIdle)
select {
case <-stallTimer.C:
t.Fatal("stalled")
@@ -426,6 +445,7 @@ func TestAutoUpdateDisabledSkipsDownload(t *testing.T) {
}
updater.StartBackgroundUpdaterChecker(ctx, cb)
t.Cleanup(updater.waitDownloadIdle)
// Wait enough time for multiple check cycles
time.Sleep(50 * time.Millisecond)
@@ -488,6 +508,7 @@ func TestAutoUpdateReenabledDownloadsUpdate(t *testing.T) {
}
upd.StartBackgroundUpdaterChecker(ctx, cb)
t.Cleanup(upd.waitDownloadIdle)
// Wait for a few cycles with auto-update disabled - no download should happen
time.Sleep(50 * time.Millisecond)
@@ -556,7 +577,9 @@ func TestCancelOngoingDownload(t *testing.T) {
_, resp := updater.checkForUpdate(ctx)
// Start download in goroutine
downloadDone := make(chan struct{})
go func() {
defer close(downloadDone)
_ = updater.DownloadNewRelease(ctx, resp)
}()
@@ -577,6 +600,10 @@ func TestCancelOngoingDownload(t *testing.T) {
case <-time.After(2 * time.Second):
t.Fatal("download cancellation was not received by server")
}
// Wait for the download goroutine to unwind: it drags along a background
// update-check loop that reads package-level knobs the next test rewrites.
<-downloadDone
}
func TestTriggerImmediateCheck(t *testing.T) {
@@ -615,6 +642,7 @@ func TestTriggerImmediateCheck(t *testing.T) {
}
updater.StartBackgroundUpdaterChecker(ctx, cb)
t.Cleanup(updater.waitDownloadIdle)
// Wait for the initial check that fires after the initial delay
select {
+12 -8
View File
@@ -191,7 +191,18 @@ if(OLLAMA_MLX_BACKENDS)
USES_TERMINAL_DOWNLOAD TRUE)
list(APPEND _mlx_source_targets ollama-mlx-c-source)
endif()
add_custom_target(ollama-mlx-sources DEPENDS ${_mlx_source_targets})
# Refresh the vendored MLX-C headers once the sources are present. Every MLX
# backend variant shares this destination in the source tree, so the copy has
# to happen here rather than in each variant's build.
add_custom_target(ollama-mlx-vendor-headers
COMMAND ${CMAKE_COMMAND}
-DMLX_C_HEADERS_DIR=${OLLAMA_MLX_C_SOURCE_DIR}/mlx/c
-DMLX_C_HEADERS_DEST=${CMAKE_SOURCE_DIR}/x/mlxrunner/mlx/include/mlx/c
-P "${CMAKE_SOURCE_DIR}/cmake/vendor-mlx-c-headers.cmake"
DEPENDS ${_mlx_source_targets}
COMMENT "Vendoring MLX-C headers"
VERBATIM)
add_custom_target(ollama-mlx-sources DEPENDS ollama-mlx-vendor-headers)
endif()
set(OLLAMA_BUILD_PARALLEL "" CACHE STRING
@@ -528,15 +539,8 @@ endfunction()
find_program(GO_EXECUTABLE go)
if(OLLAMA_MLX_BACKENDS)
set(_mlx_c_headers_dir "${OLLAMA_MLX_C_SOURCE_DIR}/mlx/c")
set(_mlx_c_headers_dest "${CMAKE_SOURCE_DIR}/x/mlxrunner/mlx/include/mlx/c")
if(GO_EXECUTABLE AND (NOT APPLE OR CMAKE_SYSTEM_PROCESSOR STREQUAL CMAKE_HOST_SYSTEM_PROCESSOR))
add_custom_target(ollama-mlx-generate-wrappers
COMMAND ${CMAKE_COMMAND}
-DMLX_C_HEADERS_DIR=${_mlx_c_headers_dir}
-DMLX_C_HEADERS_DEST=${_mlx_c_headers_dest}
-P "${CMAKE_SOURCE_DIR}/cmake/vendor-mlx-c-headers.cmake"
COMMAND ${CMAKE_COMMAND} -E env
CC= CGO_CFLAGS= CGO_CXXFLAGS=
${GO_EXECUTABLE} generate ./x/...
+30 -5
View File
@@ -50,7 +50,12 @@ endif()
option(OLLAMA_MLX_GENERATE_WRAPPERS "Regenerate MLX Go wrappers" OFF)
message(STATUS "Setting up MLX (this takes a while...)")
add_subdirectory(${OLLAMA_SOURCE_DIR}/x/imagegen/mlx ${CMAKE_BINARY_DIR}/x/imagegen/mlx)
foreach(_cudnn_var CUDNN_INCLUDE_PATH CUDNN_LIBRARY_PATH)
if((NOT DEFINED ${_cudnn_var} OR "${${_cudnn_var}}" STREQUAL "") AND DEFINED ENV{${_cudnn_var}})
set(${_cudnn_var} "$ENV{${_cudnn_var}}" CACHE PATH "")
endif()
endforeach()
add_subdirectory(${OLLAMA_SOURCE_DIR}/x/mlxrunner/mlx ${CMAKE_BINARY_DIR}/x/mlxrunner/mlx)
# Find CUDA toolkit if MLX is built with CUDA support.
find_package(CUDAToolkit)
@@ -65,6 +70,7 @@ elseif(DEFINED ENV{CUDNN_ROOT_DIR})
set(_cudnn_root "$ENV{CUDNN_ROOT_DIR}")
endif()
if(_cudnn_root)
file(TO_CMAKE_PATH "${_cudnn_root}" _cudnn_root)
# cuDNN 9.x has versioned subdirectories under bin/ (e.g., bin/13.0/).
file(GLOB CUDNN_BIN_SUBDIRS "${_cudnn_root}/bin/*")
list(APPEND MLX_RUNTIME_DIRS ${CUDNN_BIN_SUBDIRS})
@@ -293,9 +299,10 @@ endif()
# RUNTIME_DEPENDENCIES auto-excludes it via POST_EXCLUDE_FILES_STRICT because
# dlfcn-win32 is a known CMake target with its own install rules (which install
# to the wrong destination). We must install it explicitly here.
if(WIN32)
install(FILES ${OLLAMA_BUILD_DIR}/dl.dll
DESTINATION ${OLLAMA_INSTALL_DIR}
if(WIN32 AND TARGET dl)
install(TARGETS dl
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR}
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR}
COMPONENT MLX)
endif()
@@ -309,7 +316,25 @@ if(CUDAToolkit_FOUND)
"${CUDAToolkit_LIBRARY_DIR}/libnvrtc.so*"
"${CUDAToolkit_LIBRARY_DIR}/libnvrtc-builtins.so*"
"${CUDAToolkit_LIBRARY_DIR}/libcufft.so*"
"${CUDAToolkit_LIBRARY_DIR}/libcudnn.so*")
"${CUDAToolkit_LIBRARY_DIR}/libcudnn*.so*")
if(WIN32)
file(GLOB MLX_CUDA_DLLS
"${CUDAToolkit_BIN_DIR}/nvrtc-builtins64_*.dll"
"${CUDAToolkit_BIN_DIR}/x64/nvrtc-builtins64_*.dll")
list(APPEND MLX_CUDA_LIBS ${MLX_CUDA_DLLS})
endif()
find_library(MLX_CUDNN_LIBRARY NAMES cudnn HINTS "$ENV{CUDNN_LIBRARY_PATH}")
if(MLX_CUDNN_LIBRARY)
get_filename_component(MLX_CUDNN_LIBRARY_DIR "${MLX_CUDNN_LIBRARY}" DIRECTORY)
file(GLOB MLX_CUDNN_LIBS "${MLX_CUDNN_LIBRARY_DIR}/libcudnn*.so*")
list(APPEND MLX_CUDA_LIBS ${MLX_CUDNN_LIBS})
endif()
if(WIN32 AND _cudnn_root)
file(GLOB MLX_CUDNN_DLLS
"${_cudnn_root}/bin/${CUDAToolkit_VERSION_MAJOR}.0/cudnn*.dll"
"${_cudnn_root}/bin/x64/cudnn*.dll")
list(APPEND MLX_CUDA_LIBS ${MLX_CUDNN_DLLS})
endif()
if(MLX_CUDA_LIBS)
install(FILES ${MLX_CUDA_LIBS}
DESTINATION ${OLLAMA_INSTALL_DIR}
+2
View File
@@ -17,6 +17,8 @@
"inherits": [ "default" ],
"cacheVariables": {
"CMAKE_CUDA_FLAGS": "-t 2",
"MLX_BUILD_CUDA": "ON",
"MLX_BUILD_METAL": "OFF",
"OLLAMA_RUNNER_DIR": "mlx_cuda_v13"
}
},
+30 -239
View File
@@ -28,7 +28,6 @@ import (
type agentTUIOptions struct {
Model string
OpenModelPicker bool
System string
Format string
Options map[string]any
@@ -40,142 +39,6 @@ type agentTUIOptions struct {
MultiModal bool
}
func registerAgentFlags(cmd *cobra.Command) {
cmd.Flags().String("model", "", "Model to use")
cmd.Flags().String("keepalive", "", "Duration to keep a model loaded (e.g. 5m)")
cmd.Flags().String("format", "", "Response format (e.g. json)")
cmd.Flags().String("think", "", "Enable thinking mode: true/false or high/medium/low for supported models")
cmd.Flags().Lookup("think").NoOptDefVal = "true"
cmd.Flags().Bool("auto-approve-tools", false, "Allow agent tools to run without prompting")
cmd.Flags().Bool("yolo", false, "Alias for --auto-approve-tools")
cmd.Flags().Bool("no-tools", false, "Disable agent tools")
}
func AgentHandler(cmd *cobra.Command, _ []string) error {
opts := agentTUIOptions{
Model: strings.TrimSpace(config.LastModel()),
Options: map[string]any{},
}
thinkExplicit, err := applyAgentFlags(cmd, &opts)
if err != nil {
return err
}
if strings.TrimSpace(opts.Model) == "" {
opts.OpenModelPicker = true
} else if cmd.Flags().Lookup("model") == nil || !cmd.Flags().Lookup("model").Changed {
opts.OpenModelPicker = true
}
client, err := api.ClientFromEnvironment()
if err != nil {
return err
}
if opts.OpenModelPicker {
modelName, err := selectAgentModel(cmd.Context(), client, opts.Model)
if errors.Is(err, launch.ErrCancelled) {
return nil
}
if err != nil {
return err
}
opts.Model = modelName
opts.OpenModelPicker = false
}
if strings.TrimSpace(opts.Model) != "" {
info, err := prepareAgentModel(cmd, client, &opts, thinkExplicit)
if err != nil {
if handleCloudAuthorizationError(err) {
return nil
}
return err
}
opts.System = info.System
if err := saveLastAgentModel(opts.Model); err != nil {
return err
}
}
if err := GenerateAgentTUI(cmd, client, opts); err != nil {
if handleCloudAuthorizationError(err) {
return nil
}
return fmt.Errorf("error running agent: %w", err)
}
return nil
}
func applyAgentFlags(cmd *cobra.Command, opts *agentTUIOptions) (bool, error) {
if flag := cmd.Flags().Lookup("model"); flag != nil && flag.Changed {
modelName, err := cmd.Flags().GetString("model")
if err != nil {
return false, err
}
modelName = strings.TrimSpace(modelName)
if modelName == "" {
return false, errors.New("--model cannot be empty")
}
opts.Model = modelName
opts.OpenModelPicker = false
}
format, err := cmd.Flags().GetString("format")
if err != nil {
return false, err
}
opts.Format = format
thinkExplicit := false
thinkFlag := cmd.Flags().Lookup("think")
if thinkFlag != nil && thinkFlag.Changed {
thinkExplicit = true
thinkStr, err := cmd.Flags().GetString("think")
if err != nil {
return false, err
}
switch thinkStr {
case "", "true":
opts.Think = &api.ThinkValue{Value: true}
case "false":
opts.Think = &api.ThinkValue{Value: false}
case "high", "medium", "low", "max":
opts.Think = &api.ThinkValue{Value: thinkStr}
default:
return false, fmt.Errorf("invalid value for --think: %q (must be true, false, high, medium, low, or max)", thinkStr)
}
}
keepAlive, err := cmd.Flags().GetString("keepalive")
if err != nil {
return false, err
}
if keepAlive != "" {
d, err := time.ParseDuration(keepAlive)
if err != nil {
return false, err
}
opts.KeepAlive = &api.Duration{Duration: d}
}
autoApprove, err := cmd.Flags().GetBool("auto-approve-tools")
if err != nil {
return false, err
}
yolo, err := cmd.Flags().GetBool("yolo")
if err != nil {
return false, err
}
opts.AllowAllTools = autoApprove || yolo
toolsDisabled, err := cmd.Flags().GetBool("no-tools")
if err != nil {
return false, err
}
opts.ToolsDisabled = toolsDisabled
return thinkExplicit, nil
}
func saveLastAgentModel(model string) error {
model = strings.TrimSpace(model)
if model == "" {
@@ -185,24 +48,16 @@ func saveLastAgentModel(model string) error {
}
func prepareAgentModel(cmd *cobra.Command, client *api.Client, opts *agentTUIOptions, thinkExplicit bool) (*api.ShowResponse, error) {
requestedCloud := modelref.HasExplicitCloudSource(opts.Model)
info, err := func() (*api.ShowResponse, error) {
info, err := client.Show(cmd.Context(), &api.ShowRequest{Model: opts.Model})
var se api.StatusError
if errors.As(err, &se) && se.StatusCode == http.StatusNotFound {
if requestedCloud {
return nil, err
}
if err := PullHandler(cmd, []string{opts.Model}); err != nil {
return nil, err
}
return client.Show(cmd.Context(), &api.ShowRequest{Model: opts.Model})
}
return info, err
}()
// Unlike `ollama run`, the bare `ollama` root command doesn't define
// --insecure, so GetBool would error; treat it as false.
insecure, _ := cmd.Flags().GetBool("insecure")
info, resolved, err := showOrPullModel(cmd, client, opts.Model, insecure, "run")
if err != nil {
return nil, err
}
// The model may have been resolved to a different name (e.g. its
// ":cloud" variant).
opts.Model = resolved
ensureCloudStub(cmd.Context(), client, opts.Model)
opts.Think, err = inferThinkingOption(&info.Capabilities, &runOptions{Model: opts.Model, Think: opts.Think}, thinkExplicit)
@@ -220,12 +75,23 @@ func GenerateAgentTUI(cmd *cobra.Command, client *api.Client, opts agentTUIOptio
return agentContextWindowForModel(ctx, client, model, fallback)
}
skillCatalog, err := coreagent.LoadDefaultSkills(cwd)
if err != nil {
return fmt.Errorf("load agent skills: %w", err)
var skillCatalog *coreagent.SkillCatalog
reloadSkills := func() (*coreagent.SkillCatalog, error) {
catalog, err := coreagent.LoadDefaultSkills(cwd)
if err != nil {
return nil, err
}
if ignored := catalog.ExcludeNames(agentchat.BuiltinSlashCommandNames()); len(ignored) > 0 {
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m ignoring agent skill(s): %s\n", strings.Join(ignored, ", "))
}
for _, diagnostic := range catalog.Diagnostics() {
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m ignored invalid agent skill: %v\n", diagnostic)
}
skillCatalog = catalog
return catalog, nil
}
for _, diagnostic := range skillCatalog.Diagnostics() {
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m ignored invalid agent skill: %v\n", diagnostic)
if _, err := reloadSkills(); err != nil {
return fmt.Errorf("load agent skills: %w", err)
}
var registry *coreagent.Registry
registryForModel := func(ctx context.Context, model string) *coreagent.Registry {
@@ -236,7 +102,7 @@ func GenerateAgentTUI(cmd *cobra.Command, client *api.Client, opts agentTUIOptio
}
systemPrompt := agentSystemPromptWithWorkingDir(opts.Model, opts.System, agentSkillSystemContext(skillCatalog, registry, opts.ToolsDisabled), cwd)
_, err = agentchat.Run(cmd.Context(), agentchat.Options{
_, err := agentchat.Run(cmd.Context(), agentchat.Options{
Model: opts.Model,
Client: client,
Tools: registry,
@@ -255,6 +121,8 @@ func GenerateAgentTUI(cmd *cobra.Command, client *api.Client, opts agentTUIOptio
return agentSystemPromptWithWorkingDir(model, agentSystemFromShow(ctx, client, model), agentSkillSystemContext(skillCatalog, registry, toolsDisabled), cwd)
},
Skills: skillCatalog,
ImportSkills: coreagent.ImportSkills,
ReloadSkills: reloadSkills,
SystemPrompt: systemPrompt,
WorkingDir: cwd,
Format: opts.Format,
@@ -302,32 +170,12 @@ func agentSkillSystemContext(catalog *coreagent.SkillCatalog, registry *coreagen
return catalog.SystemContext()
}
func selectAgentModel(ctx context.Context, client *api.Client, current string) (string, error) {
models, err := agentModelOptions(ctx, client)
if err != nil {
return "", err
}
if len(models) == 0 {
return "", errors.New("no models available, run 'ollama pull <model>' first")
}
items := agentSelectionItems(models)
switch {
case launch.DefaultSingleSelectorWithUpdates != nil:
return launch.DefaultSingleSelectorWithUpdates("Select model to run:", items, current, nil)
case launch.DefaultSingleSelector != nil:
return launch.DefaultSingleSelector("Select model to run:", items, current)
default:
return "", errors.New("no selector configured")
}
}
func agentSelectionItems(models []agentchat.ModelOption) []launch.SelectionItem {
items := make([]launch.SelectionItem, 0, len(models))
for _, model := range models {
items = append(items, launch.SelectionItem{
Name: model.Name,
Description: agentSelectionDescription(model),
Description: strings.TrimSpace(model.Description),
Recommended: model.Recommended,
AvailabilityBadge: model.AvailabilityBadge,
})
@@ -335,10 +183,6 @@ func agentSelectionItems(models []agentchat.ModelOption) []launch.SelectionItem
return items
}
func agentSelectionDescription(model agentchat.ModelOption) string {
return strings.TrimSpace(model.Description)
}
var agentGetwd = os.Getwd
func agentWorkingDir() string {
@@ -477,7 +321,7 @@ func agentContextWindowForModel(ctx context.Context, client *api.Client, modelNa
if client == nil || strings.TrimSpace(modelName) == "" {
return fallback
}
if tokens := loadedContextWindowForModel(ctx, client, modelName); tokens > 0 {
if tokens := launch.LoadedContextWindow(ctx, client, modelName); tokens > 0 {
return tokens
}
if modelref.HasExplicitCloudSource(modelName) {
@@ -511,40 +355,13 @@ func contextWindowFromRecommendations(modelName string, recommendations []api.Mo
if rec.ContextLength <= 0 {
continue
}
if sameModelRef(modelName, rec.Model) {
if launch.SameModelRef(modelName, rec.Model) {
return rec.ContextLength
}
}
return 0
}
func sameModelRef(a, b string) bool {
a = comparableModelRef(a)
b = comparableModelRef(b)
if strings.EqualFold(a, b) {
return true
}
pa, errA := modelref.ParseRef(a)
pb, errB := modelref.ParseRef(b)
if errA != nil || errB != nil {
return false
}
if !strings.EqualFold(pa.Base, pb.Base) {
return false
}
return pa.Source == pb.Source ||
pa.Source == modelref.ModelSourceUnspecified ||
pb.Source == modelref.ModelSourceUnspecified
}
func comparableModelRef(value string) string {
value = strings.TrimSpace(value)
if strings.HasSuffix(strings.ToLower(value), ":latest") {
return strings.TrimSpace(value[:len(value)-len(":latest")])
}
return value
}
func showResponseContextWindow(resp *api.ShowResponse) int {
if resp == nil {
return 0
@@ -616,33 +433,7 @@ func preloadAgentModelIfLocal(ctx context.Context, client *api.Client, opts agen
}); err != nil {
return 0, err
}
return loadedContextWindowForModel(ctx, client, modelName), nil
}
func loadedContextWindowForModel(ctx context.Context, client *api.Client, modelName string) int {
if client == nil || strings.TrimSpace(modelName) == "" {
return 0
}
resp, err := client.ListRunning(ctx)
if err != nil {
return 0
}
return processContextWindowForModel(modelName, resp)
}
func processContextWindowForModel(modelName string, resp *api.ProcessResponse) int {
if resp == nil {
return 0
}
for _, running := range resp.Models {
if running.ContextLength <= 0 {
continue
}
if sameModelRef(modelName, running.Name) || sameModelRef(modelName, running.Model) {
return running.ContextLength
}
}
return 0
return launch.LoadedContextWindow(ctx, client, modelName), nil
}
func agentModelOptions(ctx context.Context, client *api.Client) ([]agentchat.ModelOption, error) {
+33 -30
View File
@@ -9,8 +9,6 @@ import (
"testing"
"time"
"github.com/spf13/cobra"
coreagent "github.com/ollama/ollama/agent"
agenttools "github.com/ollama/ollama/agent/tools"
"github.com/ollama/ollama/api"
@@ -97,6 +95,39 @@ func TestAgentSkillSystemContextRequiresAvailableEnabledSkillTool(t *testing.T)
}
}
func TestAgentSkillCommandCollisionsAreIgnored(t *testing.T) {
dir := t.TempDir()
for _, name := range []string{"release-notes", "system", "exit"} {
if err := os.Mkdir(filepath.Join(dir, name), 0o755); err != nil {
t.Fatal(err)
}
content := "---\nname: " + name + "\ndescription: Test skill.\n---\nInstructions."
if err := os.WriteFile(filepath.Join(dir, name, "SKILL.md"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
catalog, err := coreagent.DiscoverSkills(dir)
if err != nil {
t.Fatal(err)
}
ignored := catalog.ExcludeNames(agentchat.BuiltinSlashCommandNames())
if got, want := strings.Join(ignored, ", "), "exit, system"; got != want {
t.Fatalf("ignored skills = %q, want %q", got, want)
}
if _, err := catalog.Load("release-notes"); err != nil {
t.Fatalf("non-conflicting skill should remain available: %v", err)
}
if context := catalog.SystemContext(); !strings.Contains(context, "release-notes: Test skill.") || strings.Contains(context, "system: Test skill.") || strings.Contains(context, "exit: Test skill.") {
t.Fatalf("skill context = %q", context)
}
for _, name := range []string{"system", "exit"} {
if _, err := catalog.Load(name); err == nil {
t.Fatalf("conflicting skill %q should be ignored", name)
}
}
}
func TestAgentSelectionItemsUseLaunchSections(t *testing.T) {
items := agentSelectionItems([]agentchat.ModelOption{
{Name: "glm-5.2:cloud", Description: "cloud", Recommended: true, Cloud: true},
@@ -139,18 +170,6 @@ func TestShowResponseContextWindowReadsArchitectureContextLength(t *testing.T) {
}
}
func TestProcessContextWindowForModelMatchesLatestAlias(t *testing.T) {
got := processContextWindowForModel("ornith", &api.ProcessResponse{
Models: []api.ProcessModelResponse{
{Name: "other:latest", Model: "other:latest", ContextLength: 32768},
{Name: "ornith:latest", Model: "ornith:latest", ContextLength: 262144},
},
})
if got != 262144 {
t.Fatalf("context window = %d, want 262144", got)
}
}
func TestSaveLastAgentModel(t *testing.T) {
setCmdTestHome(t, t.TempDir())
@@ -168,19 +187,3 @@ func TestSaveLastAgentModel(t *testing.T) {
t.Fatalf("blank save changed last model to %q", got)
}
}
func TestApplyAgentFlagsNoTools(t *testing.T) {
cmd := &cobra.Command{}
registerAgentFlags(cmd)
if err := cmd.Flags().Set("no-tools", "true"); err != nil {
t.Fatal(err)
}
var opts agentTUIOptions
if _, err := applyAgentFlags(cmd, &opts); err != nil {
t.Fatalf("applyAgentFlags returned error: %v", err)
}
if !opts.ToolsDisabled {
t.Fatal("--no-tools should disable tools")
}
}
-13
View File
@@ -1,13 +0,0 @@
//go:build !windows
package cmd
import "syscall"
// backgroundServerSysProcAttr returns SysProcAttr for running the server in the background on Unix.
// Setpgid prevents the server from being killed when the parent process exits.
func backgroundServerSysProcAttr() *syscall.SysProcAttr {
return &syscall.SysProcAttr{
Setpgid: true,
}
}
-12
View File
@@ -1,12 +0,0 @@
package cmd
import "syscall"
// backgroundServerSysProcAttr returns SysProcAttr for running the server in the background on Windows.
// CREATE_NO_WINDOW (0x08000000) prevents a console window from appearing.
func backgroundServerSysProcAttr() *syscall.SysProcAttr {
return &syscall.SysProcAttr{
CreationFlags: 0x08000000,
HideWindow: true,
}
}
+121
View File
@@ -0,0 +1,121 @@
package cmd
import (
"context"
"fmt"
"os"
"strings"
"golang.org/x/term"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/cmd/launch"
"github.com/ollama/ollama/internal/modelref"
"github.com/ollama/ollama/types/model"
)
// for testing
var (
isInteractiveTerminal = func() bool {
return term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd()))
}
confirmCloudSuggestion = func(prompt string) (bool, error) {
// Zero-value options default to Yes being preselected.
return launch.ConfirmPromptWithOptions(prompt, launch.ConfirmOptions{})
}
)
// pullModelNotFoundMessage is how a registry 404 during pull surfaces to
// clients: os.ErrNotExist wrapped server-side and flattened into the error
// string of the pull stream.
const pullModelNotFoundMessage = "pull model manifest: file does not exist"
// isPullNotFoundErr reports whether err is a pull failure caused by the
// requested model or tag not existing in the registry.
func isPullNotFoundErr(err error) bool {
return err != nil && strings.Contains(err.Error(), pullModelNotFoundMessage)
}
// cloudSuggestionCandidate reports whether a failed pull of name should
// trigger a ":cloud" suggestion, and if so returns the cloud model name to
// suggest. It only applies to default-tag lookups (e.g. "kimi-k3") against
// the default registry whose pull failed because the tag doesn't exist.
func cloudSuggestionCandidate(name string, pullErr error, insecure bool) (string, bool) {
if !isPullNotFoundErr(pullErr) {
return "", false
}
return cloudSuggestionName(name, insecure)
}
// cloudSuggestionName applies the name-based eligibility checks for the
// ":cloud" suggestion, returning the cloud model name to suggest.
func cloudSuggestionName(name string, insecure bool) (string, bool) {
// --insecure implies a non-default registry, where an ollama.com cloud
// model wouldn't be a meaningful suggestion.
if insecure {
return "", false
}
ref, err := modelref.ParseRef(name)
if err != nil || ref.Source != modelref.ModelSourceUnspecified {
return "", false
}
if modelref.HasExplicitTag(ref.Base) {
return "", false
}
// Only default-registry names qualify: the existence probe forwards the name
// to ollama.com, and custom-registry model names shouldn't be sent there.
if n := model.ParseName(ref.Base); !n.IsValid() || !strings.EqualFold(n.Host, model.DefaultName().Host) {
return "", false
}
return ref.Base + ":cloud", true
}
// pullWithCloudSuggestion pulls `name`, and if the model's default tag
// doesn't exist but a ":cloud" tag does, offers it: either interactively via
// a confirmation prompt, or by augmenting the returned error when not at a
// terminal. It returns the name that was actually pulled. `verb` is the
// user-facing command ("run" or "pull") used in the hint text.
func pullWithCloudSuggestion(ctx context.Context, client *api.Client, name string, insecure bool, verb string) (string, error) {
// If a suggestion prompt may follow a failed pull, erase the failed
// attempt's progress display instead of leaving its "pulling manifest"
// line to stack up against the accepted pull's identical one.
_, eligible := cloudSuggestionName(name, insecure)
clearNotFound := eligible && isInteractiveTerminal()
pullErr := pullModelWithProgress(ctx, client, name, insecure, clearNotFound)
if pullErr == nil {
return name, nil
}
cloudName, ok := cloudSuggestionCandidate(name, pullErr, insecure)
if !ok || ctx.Err() != nil {
return "", pullErr
}
// Showing a ":cloud" model is proxied to ollama.com and mirrors its status,
// so this reliably answers "does a cloud version exist?". Any error (no
// cloud tag, cloud disabled, older server, offline) means no suggestion.
if _, err := client.Show(ctx, &api.ShowRequest{Model: cloudName}); err != nil {
return "", pullErr
}
if !isInteractiveTerminal() {
return "", fmt.Errorf("%w\n\n%q is available as a cloud model. Try:\n ollama %s %s", pullErr, cloudName, verb, cloudName)
}
accepted, err := confirmCloudSuggestion(fmt.Sprintf("Did you mean %q?", cloudName))
if err != nil || !accepted {
// Declining or cancelling falls back to the original error.
return "", pullErr
}
if err := pullModelWithProgress(ctx, client, cloudName, insecure, false); err != nil {
return "", err
}
return cloudName, nil
}
+411
View File
@@ -0,0 +1,411 @@
package cmd
import (
"cmp"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/cmd/launch"
"github.com/ollama/ollama/types/model"
)
func TestCloudSuggestionCandidate(t *testing.T) {
notFoundErr := errors.New("pull model manifest: file does not exist")
suggestedErr := errors.New("pull model manifest: file does not exist\n\nTry one of these models:\n some-model:cloud")
tests := []struct {
name string
model string
pullErr error
insecure bool
want string
wantOK bool
}{
{name: "default tag not found", model: "some-model", pullErr: notFoundErr, want: "some-model:cloud", wantOK: true},
{name: "composes with server tag suggestions", model: "some-model", pullErr: suggestedErr, want: "some-model:cloud", wantOK: true},
{name: "namespaced default tag", model: "user/some-model", pullErr: notFoundErr, want: "user/some-model:cloud", wantOK: true},
{name: "nil error", model: "some-model", pullErr: nil},
{name: "unrelated error", model: "some-model", pullErr: errors.New("boom")},
{name: "insecure registry", model: "some-model", pullErr: notFoundErr, insecure: true},
{name: "explicit tag", model: "some-model:9b", pullErr: notFoundErr},
{name: "explicit latest tag", model: "some-model:latest", pullErr: notFoundErr},
{name: "explicit cloud source", model: "some-model:cloud", pullErr: notFoundErr},
{name: "explicit legacy cloud tag", model: "some-model:9b-cloud", pullErr: notFoundErr},
{name: "explicit local source", model: "some-model:local", pullErr: notFoundErr},
{name: "custom registry host", model: "internal.example.com/team/private-model", pullErr: notFoundErr},
{name: "custom registry host with port", model: "registry.example.com:5000/team/private-model", pullErr: notFoundErr},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := cloudSuggestionCandidate(tt.model, tt.pullErr, tt.insecure)
if ok != tt.wantOK {
t.Fatalf("cloudSuggestionCandidate(%q) ok = %v, want %v", tt.model, ok, tt.wantOK)
}
if got != tt.want {
t.Fatalf("cloudSuggestionCandidate(%q) = %q, want %q", tt.model, got, tt.want)
}
})
}
}
// stubCloudSuggest replaces the TTY check and confirmation prompt for the
// duration of the test. If confirm is nil, any prompt fails the test.
func stubCloudSuggest(t *testing.T, interactive bool, confirm func(prompt string) (bool, error)) *[]string {
t.Helper()
oldTTY, oldConfirm := isInteractiveTerminal, confirmCloudSuggestion
t.Cleanup(func() {
isInteractiveTerminal, confirmCloudSuggestion = oldTTY, oldConfirm
})
isInteractiveTerminal = func() bool { return interactive }
prompts := &[]string{}
confirmCloudSuggestion = func(prompt string) (bool, error) {
*prompts = append(*prompts, prompt)
if confirm == nil {
t.Errorf("unexpected cloud suggestion prompt: %q", prompt)
return false, nil
}
return confirm(prompt)
}
return prompts
}
type cloudSuggestServer struct {
cloudName string // model name whose show/pull succeeds (e.g. "some-model:cloud")
cloudExists bool // whether showing/pulling cloudName succeeds
pullErr string // error message for failing pulls
showModels []string
pullModels []string
generateModels []string
}
// start serves mock /api/show, /api/pull, /api/tags, and /api/generate
// endpoints: only cloudName is known (when cloudExists), and pulling any other
// model fails with pullErr streamed the way real servers do (an in-band error
// under HTTP 200).
func (s *cloudSuggestServer) start(t *testing.T) {
t.Helper()
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/show" && r.Method == http.MethodPost:
var req api.ShowRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
name := cmp.Or(req.Model, req.Name)
s.showModels = append(s.showModels, name)
if s.cloudExists && name == s.cloudName {
if err := json.NewEncoder(w).Encode(api.ShowResponse{
Capabilities: []model.Capability{model.CapabilityCompletion},
RemoteModel: strings.TrimSuffix(s.cloudName, ":cloud"),
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
w.WriteHeader(http.StatusNotFound)
if err := json.NewEncoder(w).Encode(map[string]string{
"error": "model '" + name + "' not found",
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
case r.URL.Path == "/api/pull" && r.Method == http.MethodPost:
var req api.PullRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
name := cmp.Or(req.Model, req.Name)
s.pullModels = append(s.pullModels, name)
var body any
if s.cloudExists && name == s.cloudName {
body = api.ProgressResponse{Status: "success"}
} else {
body = map[string]string{"error": s.pullErr}
}
if err := json.NewEncoder(w).Encode(body); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
case r.URL.Path == "/api/tags" && r.Method == http.MethodGet:
if err := json.NewEncoder(w).Encode(api.ListResponse{
Models: []api.ListModelResponse{{Name: s.cloudName}},
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
case r.URL.Path == "/api/generate" && r.Method == http.MethodPost:
var req api.GenerateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.generateModels = append(s.generateModels, req.Model)
if err := json.NewEncoder(w).Encode(api.GenerateResponse{Done: true}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
default:
http.NotFound(w, r)
}
}))
t.Setenv("OLLAMA_HOST", mockServer.URL)
t.Cleanup(mockServer.Close)
}
func newCloudSuggestServer(t *testing.T) *cloudSuggestServer {
t.Helper()
s := &cloudSuggestServer{
cloudName: "some-model:cloud",
cloudExists: true,
pullErr: "pull model manifest: file does not exist",
}
s.start(t)
return s
}
func newPullTestCmd(t *testing.T) *cobra.Command {
t.Helper()
cmd := &cobra.Command{}
cmd.SetContext(t.Context())
cmd.Flags().Bool("insecure", false, "")
return cmd
}
func newRunTestCmd(t *testing.T) *cobra.Command {
t.Helper()
cmd := &cobra.Command{}
cmd.SetContext(t.Context())
cmd.Flags().String("keepalive", "", "")
cmd.Flags().Bool("truncate", false, "")
cmd.Flags().Int("dimensions", 0, "")
cmd.Flags().Bool("verbose", false, "")
cmd.Flags().Bool("insecure", false, "")
cmd.Flags().Bool("nowordwrap", false, "")
cmd.Flags().String("format", "", "")
cmd.Flags().String("think", "", "")
cmd.Flags().Bool("hidethinking", false, "")
return cmd
}
func TestPullHandler_SuccessfulPullNoSuggestion(t *testing.T) {
server := newCloudSuggestServer(t)
server.cloudName = "some-model" // the requested model itself pulls fine
stubCloudSuggest(t, true, nil)
if err := PullHandler(newPullTestCmd(t), []string{"some-model"}); err != nil {
t.Fatalf("PullHandler returned error: %v", err)
}
if want := []string{"some-model"}; !slices.Equal(server.pullModels, want) {
t.Fatalf("pulled models = %v, want %v", server.pullModels, want)
}
if len(server.showModels) != 0 {
t.Fatalf("show models = %v, want no probe after a successful pull", server.showModels)
}
}
func TestPullHandler_CloudSuggestionAccepted(t *testing.T) {
server := newCloudSuggestServer(t)
prompts := stubCloudSuggest(t, true, func(string) (bool, error) { return true, nil })
if err := PullHandler(newPullTestCmd(t), []string{"some-model"}); err != nil {
t.Fatalf("PullHandler returned error: %v", err)
}
if want := []string{"some-model", "some-model:cloud"}; !slices.Equal(server.pullModels, want) {
t.Fatalf("pulled models = %v, want %v", server.pullModels, want)
}
if len(*prompts) != 1 || !strings.Contains((*prompts)[0], `"some-model:cloud"`) {
t.Fatalf("prompts = %v, want one prompt mentioning some-model:cloud", *prompts)
}
}
func TestPullHandler_CloudSuggestionDeclined(t *testing.T) {
server := newCloudSuggestServer(t)
stubCloudSuggest(t, true, func(string) (bool, error) { return false, nil })
err := PullHandler(newPullTestCmd(t), []string{"some-model"})
if err == nil {
t.Fatal("PullHandler returned nil, want an error")
}
if !strings.Contains(err.Error(), "pull model manifest: file does not exist") {
t.Fatalf("error = %q, want it to contain the original pull error", err)
}
if strings.Contains(err.Error(), "Try:") {
t.Fatalf("error = %q, want no non-interactive hint after declining", err)
}
if want := []string{"some-model"}; !slices.Equal(server.pullModels, want) {
t.Fatalf("pulled models = %v, want %v", server.pullModels, want)
}
}
func TestPullHandler_CloudSuggestionCancelled(t *testing.T) {
server := newCloudSuggestServer(t)
stubCloudSuggest(t, true, func(string) (bool, error) { return false, launch.ErrCancelled })
err := PullHandler(newPullTestCmd(t), []string{"some-model"})
if err == nil {
t.Fatal("PullHandler returned nil, want an error")
}
if errors.Is(err, launch.ErrCancelled) {
t.Fatalf("error = %v, want the original pull error rather than ErrCancelled", err)
}
if !strings.Contains(err.Error(), "pull model manifest: file does not exist") {
t.Fatalf("error = %q, want it to contain the original pull error", err)
}
if want := []string{"some-model"}; !slices.Equal(server.pullModels, want) {
t.Fatalf("pulled models = %v, want %v", server.pullModels, want)
}
}
func TestPullHandler_CloudSuggestionNonInteractive(t *testing.T) {
server := newCloudSuggestServer(t)
stubCloudSuggest(t, false, nil)
err := PullHandler(newPullTestCmd(t), []string{"some-model"})
if err == nil {
t.Fatal("PullHandler returned nil, want an error")
}
if !strings.Contains(err.Error(), "pull model manifest: file does not exist") {
t.Fatalf("error = %q, want it to contain the original pull error", err)
}
if !strings.Contains(err.Error(), "ollama pull some-model:cloud") {
t.Fatalf("error = %q, want it to hint at 'ollama pull some-model:cloud'", err)
}
if want := []string{"some-model"}; !slices.Equal(server.pullModels, want) {
t.Fatalf("pulled models = %v, want %v", server.pullModels, want)
}
}
func TestPullHandler_CloudSuggestionNoCloudTag(t *testing.T) {
server := newCloudSuggestServer(t)
server.cloudExists = false
stubCloudSuggest(t, true, nil)
err := PullHandler(newPullTestCmd(t), []string{"some-model"})
if err == nil || err.Error() != "pull model manifest: file does not exist" {
t.Fatalf("error = %v, want the unmodified pull error", err)
}
if want := []string{"some-model:cloud"}; !slices.Equal(server.showModels, want) {
t.Fatalf("show models = %v, want the cloud existence probe %v", server.showModels, want)
}
}
func TestPullHandler_CloudSuggestionExplicitTag(t *testing.T) {
server := newCloudSuggestServer(t)
stubCloudSuggest(t, true, nil)
err := PullHandler(newPullTestCmd(t), []string{"some-model:9b"})
if err == nil || err.Error() != "pull model manifest: file does not exist" {
t.Fatalf("error = %v, want the unmodified pull error", err)
}
if len(server.showModels) != 0 {
t.Fatalf("show models = %v, want no cloud probe for explicitly tagged models", server.showModels)
}
}
func TestPullHandler_CloudSuggestionExplicitCloud(t *testing.T) {
server := newCloudSuggestServer(t)
server.cloudExists = false // make the explicit :cloud pull fail too
stubCloudSuggest(t, true, nil)
err := PullHandler(newPullTestCmd(t), []string{"some-model:cloud"})
if err == nil || err.Error() != "pull model manifest: file does not exist" {
t.Fatalf("error = %v, want the unmodified pull error", err)
}
if len(server.showModels) != 0 {
t.Fatalf("show models = %v, want no probe for explicit :cloud requests", server.showModels)
}
}
func TestPullHandler_CloudSuggestionInsecure(t *testing.T) {
server := newCloudSuggestServer(t)
stubCloudSuggest(t, true, nil)
cmd := newPullTestCmd(t)
if err := cmd.Flags().Set("insecure", "true"); err != nil {
t.Fatal(err)
}
err := PullHandler(cmd, []string{"some-model"})
if err == nil || err.Error() != "pull model manifest: file does not exist" {
t.Fatalf("error = %v, want the unmodified pull error", err)
}
if len(server.showModels) != 0 {
t.Fatalf("show models = %v, want no probe for --insecure pulls", server.showModels)
}
}
func TestPullHandler_CloudSuggestionUnrelatedError(t *testing.T) {
server := newCloudSuggestServer(t)
server.pullErr = "boom"
stubCloudSuggest(t, true, nil)
err := PullHandler(newPullTestCmd(t), []string{"some-model"})
if err == nil || err.Error() != "boom" {
t.Fatalf("error = %v, want the unmodified pull error %q", err, "boom")
}
if len(server.showModels) != 0 {
t.Fatalf("show models = %v, want no probe for unrelated pull errors", server.showModels)
}
}
func TestRunHandler_CloudSuggestionAccepted_RunsCloudModel(t *testing.T) {
server := newCloudSuggestServer(t)
stubCloudSuggest(t, true, func(string) (bool, error) { return true, nil })
if err := RunHandler(newRunTestCmd(t), []string{"some-model", "hi"}); err != nil {
t.Fatalf("RunHandler returned error: %v", err)
}
if want := []string{"some-model", "some-model:cloud"}; !slices.Equal(server.pullModels, want) {
t.Fatalf("pulled models = %v, want %v", server.pullModels, want)
}
if want := []string{"some-model:cloud"}; !slices.Equal(server.generateModels, want) {
t.Fatalf("generate models = %v, want %v", server.generateModels, want)
}
}
func TestRunHandler_CloudSuggestionDeclined_ReturnsNotFound(t *testing.T) {
server := newCloudSuggestServer(t)
stubCloudSuggest(t, true, func(string) (bool, error) { return false, nil })
err := RunHandler(newRunTestCmd(t), []string{"some-model", "hi"})
if err == nil {
t.Fatal("RunHandler returned nil, want an error")
}
if !strings.Contains(err.Error(), "pull model manifest: file does not exist") {
t.Fatalf("error = %q, want it to contain the original pull error", err)
}
if len(server.generateModels) != 0 {
t.Fatalf("generate models = %v, want none after declining", server.generateModels)
}
}
func TestRunHandler_CloudSuggestionNonInteractive_Hint(t *testing.T) {
server := newCloudSuggestServer(t)
stubCloudSuggest(t, false, nil)
err := RunHandler(newRunTestCmd(t), []string{"some-model", "hi"})
if err == nil {
t.Fatal("RunHandler returned nil, want an error")
}
if !strings.Contains(err.Error(), "ollama run some-model:cloud") {
t.Fatalf("error = %q, want it to hint at 'ollama run some-model:cloud'", err)
}
if len(server.generateModels) != 0 {
t.Fatalf("generate models = %v, want none in non-interactive mode", server.generateModels)
}
}
+60 -84
View File
@@ -16,7 +16,6 @@ import (
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"path"
"path/filepath"
@@ -57,7 +56,6 @@ import (
"github.com/ollama/ollama/version"
xcreate "github.com/ollama/ollama/x/create"
xcreateclient "github.com/ollama/ollama/x/create/client"
"github.com/ollama/ollama/x/imagegen"
)
func init() {
@@ -193,7 +191,7 @@ func resolveExperimentalLocalModelDir(ref, filename string) string {
}
candidate := filepath.Join(filepath.Dir(filename), ref)
if xcreate.IsSafetensorsModelDir(candidate) || xcreate.IsTensorModelDir(candidate) {
if xcreate.IsSafetensorsModelDir(candidate) {
return candidate
}
@@ -231,8 +229,7 @@ func CreateHandler(cmd *cobra.Command, args []string) error {
return fmt.Errorf("invalid model name: %s", modelName)
}
// Check for --experimental flag for safetensors model creation
// This gates both safetensors LLM and imagegen model creation
// Check for --experimental flag for safetensors model creation.
experimental, _ := cmd.Flags().GetBool("experimental")
draftQuantize, _ := cmd.Flags().GetString("draft-quantize")
if experimental {
@@ -710,6 +707,32 @@ func hasListedModelName(models []api.ListModelResponse, name string) bool {
return false
}
// showOrPullModel returns model info for name, pulling the model if it isn't
// available locally. If the pull finds no default tag but a ":cloud" tag
// exists, the user may be offered the cloud model instead (see
// pullWithCloudSuggestion), in which case the returned name is the cloud
// name the caller should continue with. verb is the user-facing command
// ("run" or "pull") used in hint text.
func showOrPullModel(cmd *cobra.Command, client *api.Client, name string, insecure bool, verb string) (*api.ShowResponse, string, error) {
info, err := client.Show(cmd.Context(), &api.ShowRequest{Model: name})
if err == nil {
return info, name, nil
}
var se api.StatusError
if !errors.As(err, &se) || se.StatusCode != http.StatusNotFound || modelref.HasExplicitCloudSource(name) {
return nil, name, err
}
resolved, err := pullWithCloudSuggestion(cmd.Context(), client, name, insecure, verb)
if err != nil {
return nil, name, err
}
info, err = client.Show(cmd.Context(), &api.ShowRequest{Model: resolved})
return info, resolved, err
}
func RunHandler(cmd *cobra.Command, args []string) error {
interactive := true
@@ -805,30 +828,21 @@ func RunHandler(cmd *cobra.Command, args []string) error {
return err
}
name := args[0]
requestedCloud := modelref.HasExplicitCloudSource(name)
insecure, err := cmd.Flags().GetBool("insecure")
if err != nil {
return err
}
info, err := func() (*api.ShowResponse, error) {
showReq := &api.ShowRequest{Name: name}
info, err := client.Show(cmd.Context(), showReq)
var se api.StatusError
if errors.As(err, &se) && se.StatusCode == http.StatusNotFound {
if requestedCloud {
return nil, err
}
if err := PullHandler(cmd, []string{name}); err != nil {
return nil, err
}
return client.Show(cmd.Context(), &api.ShowRequest{Name: name})
}
return info, err
}()
info, name, err := showOrPullModel(cmd, client, args[0], insecure, "run")
if err != nil {
if handleCloudAuthorizationError(err) {
return nil
}
return err
}
// The model may have been resolved to a different name (e.g. its ":cloud"
// variant), so make sure downstream requests use it.
opts.Model = name
ensureCloudStub(cmd.Context(), client, name)
@@ -878,12 +892,8 @@ func RunHandler(cmd *cobra.Command, args []string) error {
return generateEmbedding(cmd, name, opts.Prompt, opts.KeepAlive, truncate, dimensions)
}
// Check if this is an image generation model
if slices.Contains(info.Capabilities, model.CapabilityImage) {
if opts.Prompt == "" && !interactive {
return errors.New("image generation models require a prompt. Usage: ollama run " + name + " \"your prompt here\"")
}
return imagegen.RunCLI(cmd, name, opts.Prompt, interactive, opts.KeepAlive)
return errors.New("image generation models are not currently supported")
}
if interactive {
@@ -1248,6 +1258,10 @@ func ShowHandler(cmd *cobra.Command, args []string) error {
return err
}
if slices.Contains(resp.Capabilities, model.CapabilityImage) {
return errors.New("image generation models are not currently supported")
}
if flagsSet == 1 {
switch showType {
case "license":
@@ -1509,6 +1523,15 @@ func PullHandler(cmd *cobra.Command, args []string) error {
return err
}
_, err = pullWithCloudSuggestion(cmd.Context(), client, args[0], insecure, "pull")
return err
}
// pullModelWithProgress pulls name, rendering progress to stderr. When
// clearNotFound is set and the pull fails because the model doesn't exist,
// the progress display is erased rather than left behind; callers set it
// when a ":cloud" suggestion prompt may immediately follow the failure.
func pullModelWithProgress(ctx context.Context, client *api.Client, name string, insecure, clearNotFound bool) error {
p := progress.NewProgress(os.Stderr)
defer p.Stop()
@@ -1569,8 +1592,13 @@ func PullHandler(cmd *cobra.Command, args []string) error {
return nil
}
request := api.PullRequest{Name: args[0], Insecure: insecure}
return client.Pull(cmd.Context(), &request, fn)
request := api.PullRequest{Name: name, Insecure: insecure}
err := client.Pull(ctx, &request, fn)
if clearNotFound && isPullNotFoundErr(err) {
// The deferred Stop becomes a no-op after this.
p.StopAndClear()
}
return err
}
type generateContextKey string
@@ -2099,40 +2127,6 @@ Environment Variables:
cmd.SetUsageTemplate(cmd.UsageTemplate() + envUsage)
}
// ensureServerRunning checks if the ollama server is running and starts it in the background if not.
func ensureServerRunning(ctx context.Context) error {
client, err := api.ClientFromEnvironment()
if err != nil {
return err
}
// Check if server is already running
if err := client.Heartbeat(ctx); err == nil {
return nil // server is already running
}
// Server not running, start it in the background
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("could not find executable: %w", err)
}
serverCmd := exec.CommandContext(ctx, exe, "serve")
serverCmd.Env = os.Environ()
serverCmd.SysProcAttr = backgroundServerSysProcAttr()
if err := serverCmd.Start(); err != nil {
return fmt.Errorf("failed to start server: %w", err)
}
// Wait for the server to be ready
for {
time.Sleep(500 * time.Millisecond)
if err := client.Heartbeat(ctx); err == nil {
return nil // server has started
}
}
}
func launchInteractiveModel(cmd *cobra.Command, modelName string) error {
client, err := api.ClientFromEnvironment()
if err != nil {
@@ -2166,9 +2160,9 @@ func launchInteractiveModel(cmd *cobra.Command, modelName string) error {
// runInteractiveTUI runs the main interactive TUI menu.
func runInteractiveTUI(cmd *cobra.Command) {
// Ensure the server is running before showing the TUI
if err := ensureServerRunning(cmd.Context()); err != nil {
fmt.Fprintf(os.Stderr, "Error starting server: %v\n", err)
// Ensure the server is running via the shared checkServerHeartbeat path.
if err := checkServerHeartbeat(cmd, nil); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
return
}
@@ -2365,21 +2359,6 @@ func NewCLI() *cobra.Command {
runCmd.Flags().Bool("truncate", false, "For embedding models: truncate inputs exceeding context length (default: true). Set --truncate=false to error instead")
runCmd.Flags().Int("dimensions", 0, "Truncate output embeddings to specified dimension (embedding models only)")
// Image generation flags (width, height, steps, seed, etc.)
imagegen.RegisterFlags(runCmd)
runCmd.Flags().Bool("imagegen", false, "Use the imagegen runner for LLM inference")
runCmd.Flags().MarkHidden("imagegen")
agentCmd := &cobra.Command{
Use: "agent",
Short: "Run an agent",
Args: cobra.ExactArgs(0),
PreRunE: checkServerHeartbeat,
RunE: AgentHandler,
}
registerAgentFlags(agentCmd)
stopCmd := &cobra.Command{
Use: "stop MODEL",
Short: "Stop a running model",
@@ -2510,7 +2489,6 @@ func NewCLI() *cobra.Command {
createCmd,
showCmd,
runCmd,
agentCmd,
stopCmd,
pullCmd,
pushCmd,
@@ -2522,7 +2500,6 @@ func NewCLI() *cobra.Command {
} {
switch cmd {
case runCmd:
imagegen.AppendFlagsDocs(cmd)
appendEnvDocs(cmd, []envconfig.EnvVar{envVars["OLLAMA_EDITOR"], envVars["OLLAMA_HOST"], envVars["OLLAMA_NOHISTORY"]})
case serveCmd:
appendEnvDocs(cmd, []envconfig.EnvVar{
@@ -2558,7 +2535,6 @@ func NewCLI() *cobra.Command {
createCmd,
showCmd,
runCmd,
agentCmd,
stopCmd,
pullCmd,
pushCmd,
+1 -1
View File
@@ -2079,7 +2079,7 @@ func TestRunOptions_Copy_ThinkValueVariants(t *testing.T) {
}
}
func TestShowInfoImageGen(t *testing.T) {
func TestShowInfoImageCapability(t *testing.T) {
var b bytes.Buffer
err := showInfo(&api.ShowResponse{
Details: api.ModelDetails{
+68
View File
@@ -0,0 +1,68 @@
package launch
import (
"context"
"strings"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/internal/modelref"
)
// LoadedContextWindow reports the context length model is currently running
// with, per the server's process list — the size the scheduler actually
// allocated, which VRAM fit or server configuration may hold below the
// model's trained maximum. Returns 0 when it cannot be determined.
func LoadedContextWindow(ctx context.Context, client *api.Client, model string) int {
if client == nil || strings.TrimSpace(model) == "" {
return 0
}
resp, err := client.ListRunning(ctx)
if err != nil {
return 0
}
return processContextWindow(model, resp)
}
func processContextWindow(model string, resp *api.ProcessResponse) int {
if resp == nil {
return 0
}
for _, running := range resp.Models {
if running.ContextLength <= 0 {
continue
}
if SameModelRef(model, running.Name) || SameModelRef(model, running.Model) {
return running.ContextLength
}
}
return 0
}
// SameModelRef reports whether two references name the same model, tolerating
// an explicit ":latest" tag and an unspecified source on either side.
func SameModelRef(a, b string) bool {
a = comparableModelRef(a)
b = comparableModelRef(b)
if strings.EqualFold(a, b) {
return true
}
pa, errA := modelref.ParseRef(a)
pb, errB := modelref.ParseRef(b)
if errA != nil || errB != nil {
return false
}
if !strings.EqualFold(pa.Base, pb.Base) {
return false
}
return pa.Source == pb.Source ||
pa.Source == modelref.ModelSourceUnspecified ||
pb.Source == modelref.ModelSourceUnspecified
}
func comparableModelRef(value string) string {
value = strings.TrimSpace(value)
if strings.HasSuffix(strings.ToLower(value), ":latest") {
return strings.TrimSpace(value[:len(value)-len(":latest")])
}
return value
}
+19
View File
@@ -0,0 +1,19 @@
package launch
import (
"testing"
"github.com/ollama/ollama/api"
)
func TestProcessContextWindowMatchesLatestAlias(t *testing.T) {
got := processContextWindow("ornith", &api.ProcessResponse{
Models: []api.ProcessModelResponse{
{Name: "other:latest", Model: "other:latest", ContextLength: 32768},
{Name: "ornith:latest", Model: "ornith:latest", ContextLength: 262144},
},
})
if got != 262144 {
t.Fatalf("context window = %d, want 262144", got)
}
}
+520
View File
@@ -0,0 +1,520 @@
package launch
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"github.com/ollama/ollama/cmd/config"
"github.com/ollama/ollama/cmd/internal/fileutil"
"github.com/ollama/ollama/envconfig"
"github.com/ollama/ollama/types/model"
"gopkg.in/yaml.v3"
)
const (
deepSeekHarnessIntegrationName = "dsh"
deepSeekHarnessNpmPackage = "@deepseek-ai/dsh@latest"
deepSeekHarnessProvider = "ollama"
deepSeekHarnessAPIKeyEnv = "OLLAMA_LAUNCH_DSH_API_KEY"
deepSeekHarnessWebSettings = "web-search-deepseek"
)
var (
deepSeekHarnessLookPath = exec.LookPath
deepSeekHarnessCommand = exec.Command
deepSeekHarnessGOOS = runtime.GOOS
)
// DeepSeekHarness is the Ollama-managed DeepSeek Harness integration.
// It redirects only the settings provider for this invocation to an
// Ollama-owned document. The user's normal DSH_HOME, profiles, sessions,
// credentials, and patch layers remain available and untouched.
type DeepSeekHarness struct{}
func (d *DeepSeekHarness) String() string { return "DeepSeek Harness" }
func (d *DeepSeekHarness) Run(_ string, _ []LaunchModel, args []string) error {
if err := validateDeepSeekHarnessArgs(args); err != nil {
return err
}
bin, err := deepSeekHarnessLookPath("dsh")
if err != nil {
return fmt.Errorf("dsh is not installed: %w", err)
}
patchPath, err := deepSeekHarnessPatchPath()
if err != nil {
return err
}
cmd, err := deepSeekHarnessExecutableCommand(bin, deepSeekHarnessLaunchArgs(patchPath, args))
if err != nil {
return err
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = deepSeekHarnessLaunchEnv(os.Environ())
return cmd.Run()
}
func deepSeekHarnessLaunchArgs(patchPath string, args []string) []string {
launchArgs := []string{"web", "--patch", patchPath}
return append(launchArgs, args...)
}
func validateDeepSeekHarnessArgs(args []string) error {
for _, arg := range args {
if arg == "--patch" || strings.HasPrefix(arg, "--patch=") {
return fmt.Errorf("conflicting extra argument %q: ollama launch dsh manages --patch", arg)
}
}
return nil
}
func deepSeekHarnessLaunchEnv(env []string) []string {
return deepSeekHarnessUpsertEnv(env, deepSeekHarnessAPIKeyEnv, "ollama")
}
func deepSeekHarnessUpsertEnv(env []string, key, value string) []string {
prefix := key + "="
out := make([]string, 0, len(env)+1)
for _, entry := range env {
if strings.HasPrefix(entry, prefix) {
continue
}
out = append(out, entry)
}
return append(out, prefix+value)
}
func ensureDeepSeekHarnessInstalled() (string, error) {
if path, err := deepSeekHarnessLookPath("dsh"); err == nil {
return path, nil
}
npm, err := deepSeekHarnessLookPath("npm")
if err != nil {
return "", fmt.Errorf("dsh is not installed and npm (Node.js) is required\n\nInstall Node.js first:\n https://nodejs.org/\n\nThen re-run:\n ollama launch dsh")
}
ok, err := ConfirmPrompt("DeepSeek Harness is not installed. Install with npm?")
if err != nil {
return "", err
}
if !ok {
return "", fmt.Errorf("deepseek harness installation cancelled")
}
fmt.Fprintln(os.Stderr, "\nInstalling DeepSeek Harness...")
cmd, err := deepSeekHarnessNpmCommand(npm, []string{"install", "-g", deepSeekHarnessNpmPackage})
if err != nil {
return "", err
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("failed to install deepseek harness: %w", err)
}
path, err := deepSeekHarnessLookPath("dsh")
if err != nil {
return "", fmt.Errorf("deepseek harness was installed but dsh was not found on PATH\n\nYou may need to restart your shell")
}
fmt.Fprintf(os.Stderr, "%sDeepSeek Harness installed successfully%s\n\n", ansiGreen, ansiReset)
return path, nil
}
func deepSeekHarnessExecutableCommand(bin string, args []string) (*exec.Cmd, error) {
return deepSeekHarnessNodeShimCommand(bin, []string{"node_modules", "@deepseek-ai", "dsh", "lib", "bin.js"}, args)
}
func deepSeekHarnessNpmCommand(bin string, args []string) (*exec.Cmd, error) {
return deepSeekHarnessNodeShimCommand(bin, []string{"node_modules", "npm", "bin", "npm-cli.js"}, args)
}
// Windows npm binaries are .cmd shims, which cannot be passed safely to
// CreateProcess with an argv. Invoke their JavaScript entrypoints with Node so
// passthrough arguments remain data rather than cmd.exe syntax.
func deepSeekHarnessNodeShimCommand(shim string, entrypointParts, args []string) (*exec.Cmd, error) {
if deepSeekHarnessGOOS != "windows" || !deepSeekHarnessIsCommandShim(shim) {
return deepSeekHarnessCommand(shim, args...), nil
}
node, err := deepSeekHarnessLookPath("node")
if err != nil {
return nil, fmt.Errorf("node is required to run %s on Windows: %w", filepath.Base(shim), err)
}
entrypoint := filepath.Join(append([]string{filepath.Dir(shim)}, entrypointParts...)...)
if _, err := os.Stat(entrypoint); err != nil {
return nil, fmt.Errorf("resolve Windows entrypoint for %s: %w", filepath.Base(shim), err)
}
return deepSeekHarnessCommand(node, append([]string{entrypoint}, args...)...), nil
}
func deepSeekHarnessIsCommandShim(path string) bool {
ext := strings.ToLower(filepath.Ext(path))
return ext == ".cmd" || ext == ".bat"
}
func (d *DeepSeekHarness) Paths() []string {
settingsPath, settingsErr := deepSeekHarnessSettingsPath()
patchPath, patchErr := deepSeekHarnessPatchPath()
if settingsErr != nil || patchErr != nil {
return nil
}
return []string{settingsPath, patchPath}
}
func (d *DeepSeekHarness) Configure(modelName string) error {
return d.ConfigureWithModels(modelName, []LaunchModel{fallbackLaunchModel(modelName)})
}
func (d *DeepSeekHarness) ConfigureWithModels(primary string, models []LaunchModel) error {
if strings.TrimSpace(primary) == "" {
return nil
}
if len(models) == 0 {
models = []LaunchModel{fallbackLaunchModel(primary)}
}
if selected, ok := findLaunchModel(models, primary); ok {
primary = selected.Name
}
settingsPath, err := deepSeekHarnessSettingsPath()
if err != nil {
return err
}
settings, err := readDeepSeekHarnessYAMLDocument(settingsPath)
if err != nil {
return fmt.Errorf("parse deepseek harness launch settings: %w", err)
}
if err := applyDeepSeekHarnessSettings(settings, primary, models, shouldManageOllamaWebSearch()); err != nil {
return err
}
settingsData, err := yaml.Marshal(settings)
if err != nil {
return err
}
if err := writeDeepSeekHarnessFile(settingsPath, settingsData); err != nil {
return err
}
patchPath, err := deepSeekHarnessPatchPath()
if err != nil {
return err
}
patchData, err := yaml.Marshal([]map[string]any{
{
"id": "settings",
"config": map[string]any{
"path": settingsPath,
},
},
})
if err != nil {
return err
}
return writeDeepSeekHarnessFile(patchPath, patchData)
}
func readDeepSeekHarnessYAML(path string) (map[string]any, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return make(map[string]any), nil
}
return nil, err
}
settings := make(map[string]any)
if err := yaml.Unmarshal(data, &settings); err != nil {
return nil, err
}
if settings == nil {
settings = make(map[string]any)
}
return settings, nil
}
func readDeepSeekHarnessYAMLDocument(path string) (*yaml.Node, error) {
document := &yaml.Node{Kind: yaml.DocumentNode}
data, err := os.ReadFile(path)
if err != nil {
if !os.IsNotExist(err) {
return nil, err
}
document.Content = []*yaml.Node{{Kind: yaml.MappingNode, Tag: "!!map"}}
return document, nil
}
if err := yaml.Unmarshal(data, document); err != nil {
return nil, err
}
if len(document.Content) == 0 || document.Content[0].Kind == yaml.ScalarNode && document.Content[0].Tag == "!!null" {
document.Content = []*yaml.Node{{Kind: yaml.MappingNode, Tag: "!!map"}}
}
if document.Content[0].Kind != yaml.MappingNode {
return nil, fmt.Errorf("settings root must be a mapping")
}
return document, nil
}
func applyDeepSeekHarnessSettings(document *yaml.Node, primary string, models []LaunchModel, manageWebSearch bool) error {
settings := document.Content[0]
selected := deepSeekHarnessEnsureYAMLMapping(settings, "agent-default-model")
for key, value := range map[string]string{
"provider": deepSeekHarnessProvider,
"model": primary,
} {
if err := deepSeekHarnessSetYAMLValue(selected, key, value); err != nil {
return err
}
}
llm := deepSeekHarnessEnsureYAMLMapping(settings, "llm-pi-ai")
providers := deepSeekHarnessEnsureYAMLMapping(llm, "providers")
provider := deepSeekHarnessEnsureYAMLMapping(providers, deepSeekHarnessProvider)
for key, value := range map[string]any{
"displayName": "Ollama",
"apiKeyEnv": deepSeekHarnessAPIKeyEnv,
"api": "openai-completions",
"baseURL": deepSeekHarnessBaseURL(),
"models": deepSeekHarnessModelConfigs(primary, models),
} {
if err := deepSeekHarnessSetYAMLValue(provider, key, value); err != nil {
return err
}
}
if !manageWebSearch {
return nil
}
// Harness's bundled search provider appends /messages to this /v1 base and
// sends the Anthropic web_search server tool. This is separate from the main
// model provider above; Harness does not expose a configured way to send the
// native OpenAI Responses web_search tool.
web := deepSeekHarnessEnsureYAMLMapping(settings, deepSeekHarnessWebSettings)
for key, value := range map[string]string{
"apiKeyEnv": deepSeekHarnessAPIKeyEnv,
"baseURL": deepSeekHarnessBaseURL(),
"model": primary,
} {
if err := deepSeekHarnessSetYAMLValue(web, key, value); err != nil {
return err
}
}
return nil
}
func deepSeekHarnessEnsureYAMLMapping(mapping *yaml.Node, key string) *yaml.Node {
if value := deepSeekHarnessYAMLValue(mapping, key); value != nil && value.Kind == yaml.MappingNode {
return value
}
value := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
deepSeekHarnessSetYAMLNode(mapping, key, value)
return value
}
func deepSeekHarnessSetYAMLValue(mapping *yaml.Node, key string, value any) error {
node := &yaml.Node{}
if err := node.Encode(value); err != nil {
return err
}
deepSeekHarnessSetYAMLNode(mapping, key, node)
return nil
}
func deepSeekHarnessSetYAMLNode(mapping *yaml.Node, key string, value *yaml.Node) {
for i := 0; i+1 < len(mapping.Content); i += 2 {
if mapping.Content[i].Value == key {
mapping.Content[i+1] = value
return
}
}
mapping.Content = append(mapping.Content,
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key},
value,
)
}
func deepSeekHarnessYAMLValue(mapping *yaml.Node, key string) *yaml.Node {
for i := 0; i+1 < len(mapping.Content); i += 2 {
if mapping.Content[i].Value == key {
return mapping.Content[i+1]
}
}
return nil
}
func deepSeekHarnessModelConfigs(primary string, models []LaunchModel) []any {
ordered := append([]LaunchModel(nil), models...)
if selected, ok := findLaunchModel(ordered, primary); ok {
ordered = append([]LaunchModel{selected}, removeLaunchModel(ordered, primary)...)
} else {
ordered = append([]LaunchModel{fallbackLaunchModel(primary)}, ordered...)
}
configs := make([]any, 0, len(ordered))
seen := make(map[string]bool, len(ordered))
for _, item := range ordered {
if item.Name == "" || seen[item.Name] {
continue
}
seen[item.Name] = true
entry := map[string]any{
"id": item.Name,
"name": item.Name,
"input": []string{"text"},
}
if slices.Contains(item.Capabilities, model.CapabilityVision) {
entry["input"] = []string{"text", "image"}
}
if item.ContextLength > 0 {
entry["contextWindow"] = item.ContextLength
}
if item.MaxOutputTokens > 0 {
entry["maxTokens"] = item.MaxOutputTokens
}
configs = append(configs, entry)
}
return configs
}
func (d *DeepSeekHarness) CurrentModel() string {
settingsPath, err := deepSeekHarnessSettingsPath()
if err != nil {
return ""
}
if !deepSeekHarnessPatchHealthy(settingsPath) {
return ""
}
settings, err := readDeepSeekHarnessYAML(settingsPath)
if err != nil {
return ""
}
selected, _ := settings["agent-default-model"].(map[string]any)
if selected == nil || selected["provider"] != deepSeekHarnessProvider {
return ""
}
modelName, _ := selected["model"].(string)
if modelName == "" {
return ""
}
llm, _ := settings["llm-pi-ai"].(map[string]any)
providers, _ := llm["providers"].(map[string]any)
provider, _ := providers[deepSeekHarnessProvider].(map[string]any)
if !deepSeekHarnessProviderHealthy(provider, modelName) {
return ""
}
if !shouldManageOllamaWebSearch() {
return modelName
}
web, _ := settings[deepSeekHarnessWebSettings].(map[string]any)
if !deepSeekHarnessWebProviderHealthy(web, modelName) {
return ""
}
return modelName
}
func deepSeekHarnessPatchHealthy(settingsPath string) bool {
patchPath, err := deepSeekHarnessPatchPath()
if err != nil {
return false
}
data, err := os.ReadFile(patchPath)
if err != nil {
return false
}
var patches []struct {
ID string `yaml:"id"`
Config struct {
Path string `yaml:"path"`
} `yaml:"config"`
}
if err := yaml.Unmarshal(data, &patches); err != nil || len(patches) != 1 {
return false
}
return patches[0].ID == "settings" && patches[0].Config.Path == settingsPath
}
func deepSeekHarnessProviderHealthy(provider map[string]any, modelName string) bool {
if provider == nil || provider["api"] != "openai-completions" || provider["apiKeyEnv"] != deepSeekHarnessAPIKeyEnv {
return false
}
baseURL, _ := provider["baseURL"].(string)
if strings.TrimRight(baseURL, "/") != strings.TrimRight(deepSeekHarnessBaseURL(), "/") {
return false
}
models, _ := provider["models"].([]any)
for _, raw := range models {
entry, _ := raw.(map[string]any)
if entry["id"] == modelName {
return true
}
}
return false
}
func deepSeekHarnessWebProviderHealthy(web map[string]any, modelName string) bool {
if web == nil || web["apiKeyEnv"] != deepSeekHarnessAPIKeyEnv || web["model"] != modelName {
return false
}
baseURL, _ := web["baseURL"].(string)
return strings.TrimRight(baseURL, "/") == strings.TrimRight(deepSeekHarnessBaseURL(), "/")
}
func (d *DeepSeekHarness) Onboard() error {
return config.MarkIntegrationOnboarded(deepSeekHarnessIntegrationName)
}
func (d *DeepSeekHarness) RequiresInteractiveOnboarding() bool { return false }
func deepSeekHarnessBaseURL() string {
return strings.TrimRight(envconfig.ConnectableHost().String(), "/") + "/v1"
}
func deepSeekHarnessConfigDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".ollama", "launch", "dsh"), nil
}
func deepSeekHarnessSettingsPath() (string, error) {
dir, err := deepSeekHarnessConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "settings.yaml"), nil
}
func deepSeekHarnessPatchPath() (string, error) {
dir, err := deepSeekHarnessConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "ollama.cordis.yml"), nil
}
func writeDeepSeekHarnessFile(path string, data []byte) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
if err := os.Chmod(dir, 0o700); err != nil {
return err
}
if err := fileutil.WriteWithBackup(path, data, deepSeekHarnessIntegrationName); err != nil {
return err
}
return os.Chmod(path, 0o600)
}
+461
View File
@@ -0,0 +1,461 @@
package launch
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"testing"
"github.com/ollama/ollama/types/model"
"gopkg.in/yaml.v3"
)
func TestDeepSeekHarnessRegistry(t *testing.T) {
spec, err := LookupIntegrationSpec("deepseek-harness")
if err != nil {
t.Fatal(err)
}
if spec.Name != deepSeekHarnessIntegrationName {
t.Fatalf("canonical name = %q, want %q", spec.Name, deepSeekHarnessIntegrationName)
}
if spec.Runner.String() != "DeepSeek Harness" {
t.Fatalf("display name = %q", spec.Runner.String())
}
if got := strings.Join(spec.Install.Command, " "); got != "npm install -g @deepseek-ai/dsh@latest" {
t.Fatalf("install command = %q", got)
}
}
func TestDeepSeekHarnessConfigurePreservesSettingsAndIsIdempotent(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:12345")
settingsPath, err := deepSeekHarnessSettingsPath()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o700); err != nil {
t.Fatal(err)
}
existing := []byte("# keep-comment\ndefaults: &defaults\n mode: dark\ntheme: *defaults\nagent-default-model:\n # keep-reasoning-comment\n reasoningEffort: high\nllm-pi-ai:\n providers:\n custom:\n api: openai-completions\n baseURL: https://example.invalid/v1\n models:\n - id: custom-model\n ollama:\n # keep-retry-comment\n retryPolicy:\n maxAttempts: 2\nweb-search-deepseek:\n maxUses: 3\n")
if err := os.WriteFile(settingsPath, existing, 0o600); err != nil {
t.Fatal(err)
}
models := []LaunchModel{
{Name: "qwen3.5:latest", ContextLength: 262144, MaxOutputTokens: 32768, Capabilities: []model.Capability{model.CapabilityVision}},
{Name: "kimi-k2.6:cloud", ContextLength: 262144, MaxOutputTokens: 262144},
}
dsh := &DeepSeekHarness{}
if err := dsh.ConfigureWithModels("qwen3.5", models); err != nil {
t.Fatal(err)
}
firstSettings, err := os.ReadFile(settingsPath)
if err != nil {
t.Fatal(err)
}
patchPath, err := deepSeekHarnessPatchPath()
if err != nil {
t.Fatal(err)
}
firstPatch, err := os.ReadFile(patchPath)
if err != nil {
t.Fatal(err)
}
if err := dsh.ConfigureWithModels("qwen3.5", models); err != nil {
t.Fatal(err)
}
secondSettings, _ := os.ReadFile(settingsPath)
secondPatch, _ := os.ReadFile(patchPath)
if string(firstSettings) != string(secondSettings) || string(firstPatch) != string(secondPatch) {
t.Fatal("repeated configuration changed Ollama-managed files")
}
for _, preserved := range []string{"# keep-comment", "&defaults", "*defaults", "# keep-reasoning-comment", "# keep-retry-comment"} {
if !strings.Contains(string(firstSettings), preserved) {
t.Fatalf("settings did not preserve %q:\n%s", preserved, firstSettings)
}
}
var settings map[string]any
if err := yaml.Unmarshal(firstSettings, &settings); err != nil {
t.Fatal(err)
}
if theme, _ := settings["theme"].(map[string]any); theme["mode"] != "dark" {
t.Fatalf("unrelated settings were not preserved: %#v", settings["theme"])
}
selected, _ := settings["agent-default-model"].(map[string]any)
if selected["provider"] != deepSeekHarnessProvider || selected["model"] != "qwen3.5:latest" {
t.Fatalf("default model = %#v", selected)
}
if selected["reasoningEffort"] != "high" {
t.Fatalf("default model settings were not preserved: %#v", selected)
}
llm, _ := settings["llm-pi-ai"].(map[string]any)
providers, _ := llm["providers"].(map[string]any)
if providers["custom"] == nil {
t.Fatal("custom provider was removed")
}
provider, _ := providers[deepSeekHarnessProvider].(map[string]any)
if provider["baseURL"] != "http://127.0.0.1:12345/v1" || provider["apiKeyEnv"] != deepSeekHarnessAPIKeyEnv {
t.Fatalf("Ollama provider = %#v", provider)
}
retryPolicy, _ := provider["retryPolicy"].(map[string]any)
if retryPolicy["maxAttempts"] != 2 {
t.Fatalf("Ollama provider settings were not preserved: %#v", provider)
}
configuredModels, _ := provider["models"].([]any)
if len(configuredModels) != 2 {
t.Fatalf("configured models = %#v", configuredModels)
}
local, _ := configuredModels[0].(map[string]any)
if local["id"] != "qwen3.5:latest" || local["contextWindow"] != 262144 || local["maxTokens"] != 32768 {
t.Fatalf("local model = %#v", local)
}
if got, _ := local["input"].([]any); !slices.Equal(got, []any{"text", "image"}) {
t.Fatalf("local model input = %#v", local["input"])
}
cloud, _ := configuredModels[1].(map[string]any)
if cloud["id"] != "kimi-k2.6:cloud" || cloud["contextWindow"] != 262144 || cloud["maxTokens"] != 262144 {
t.Fatalf("cloud model = %#v", cloud)
}
web, _ := settings[deepSeekHarnessWebSettings].(map[string]any)
if web["baseURL"] != "http://127.0.0.1:12345/v1" || web["apiKeyEnv"] != deepSeekHarnessAPIKeyEnv || web["model"] != "qwen3.5:latest" {
t.Fatalf("Ollama web search provider = %#v", web)
}
if web["maxUses"] != 3 {
t.Fatalf("existing web search settings were not preserved: %#v", web)
}
var patches []map[string]any
if err := yaml.Unmarshal(firstPatch, &patches); err != nil {
t.Fatal(err)
}
if len(patches) != 1 || patches[0]["id"] != "settings" {
t.Fatalf("patches = %#v", patches)
}
patchConfig, _ := patches[0]["config"].(map[string]any)
if patchConfig["path"] != settingsPath {
t.Fatalf("settings patch path = %#v", patchConfig["path"])
}
if got := dsh.CurrentModel(); got != "qwen3.5:latest" {
t.Fatalf("CurrentModel() = %q", got)
}
}
func TestDeepSeekHarnessConfigureSkipsWebSearchWhenCloudDisabled(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/status" {
fmt.Fprint(w, `{"cloud":{"disabled":true,"source":"config"}}`)
return
}
http.NotFound(w, r)
}))
t.Cleanup(srv.Close)
t.Setenv("OLLAMA_HOST", srv.URL)
settingsPath, err := deepSeekHarnessSettingsPath()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o700); err != nil {
t.Fatal(err)
}
existing := []byte("web-search-deepseek:\n maxUses: 3\n")
if err := os.WriteFile(settingsPath, existing, 0o600); err != nil {
t.Fatal(err)
}
dsh := &DeepSeekHarness{}
if err := dsh.ConfigureWithModels("qwen3.5", []LaunchModel{{Name: "qwen3.5:latest"}}); err != nil {
t.Fatal(err)
}
settings, err := readDeepSeekHarnessYAML(settingsPath)
if err != nil {
t.Fatal(err)
}
web, _ := settings[deepSeekHarnessWebSettings].(map[string]any)
if len(web) != 1 || web["maxUses"] != 3 {
t.Fatalf("web search settings = %#v", web)
}
if got := dsh.CurrentModel(); got != "qwen3.5:latest" {
t.Fatalf("CurrentModel() = %q", got)
}
}
func TestDeepSeekHarnessCurrentModelRejectsDrift(t *testing.T) {
setTestHome(t, t.TempDir())
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434")
dsh := &DeepSeekHarness{}
if err := dsh.Configure("qwen3.5"); err != nil {
t.Fatal(err)
}
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999")
if got := dsh.CurrentModel(); got != "" {
t.Fatalf("CurrentModel() = %q for stale endpoint", got)
}
}
func TestDeepSeekHarnessCurrentModelRejectsPatchDrift(t *testing.T) {
setTestHome(t, t.TempDir())
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434")
dsh := &DeepSeekHarness{}
if err := dsh.Configure("qwen3.5"); err != nil {
t.Fatal(err)
}
patchPath, err := deepSeekHarnessPatchPath()
if err != nil {
t.Fatal(err)
}
for name, data := range map[string][]byte{
"missing": nil,
"malformed": []byte("["),
"wrong path": []byte(`- id: settings
config:
path: /tmp/not-managed.yaml
`),
} {
t.Run(name, func(t *testing.T) {
if err := dsh.Configure("qwen3.5"); err != nil {
t.Fatal(err)
}
if data == nil {
if err := os.Remove(patchPath); err != nil {
t.Fatal(err)
}
} else if err := os.WriteFile(patchPath, data, 0o600); err != nil {
t.Fatal(err)
}
if got := dsh.CurrentModel(); got != "" {
t.Fatalf("CurrentModel() = %q", got)
}
})
}
}
func TestDeepSeekHarnessConfigureRejectsMalformedSettingsWithoutOverwrite(t *testing.T) {
setTestHome(t, t.TempDir())
settingsPath, err := deepSeekHarnessSettingsPath()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o700); err != nil {
t.Fatal(err)
}
malformed := []byte("llm-pi-ai: [")
if err := os.WriteFile(settingsPath, malformed, 0o600); err != nil {
t.Fatal(err)
}
err = (&DeepSeekHarness{}).Configure("qwen3.5")
if err == nil || !strings.Contains(err.Error(), "parse deepseek harness launch settings") {
t.Fatalf("Configure() error = %v", err)
}
got, err := os.ReadFile(settingsPath)
if err != nil {
t.Fatal(err)
}
if !slices.Equal(got, malformed) {
t.Fatalf("malformed settings were overwritten: %q", got)
}
}
func TestDeepSeekHarnessConfigureAcceptsNullSettings(t *testing.T) {
setTestHome(t, t.TempDir())
settingsPath, err := deepSeekHarnessSettingsPath()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(settingsPath, []byte("null\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := (&DeepSeekHarness{}).Configure("qwen3.5"); err != nil {
t.Fatal(err)
}
}
func TestDeepSeekHarnessRunUsesManagedPatchAndCredential(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX shell test binary")
}
home := t.TempDir()
setTestHome(t, home)
binDir := t.TempDir()
logPath := filepath.Join(home, "dsh-invocation")
script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$DSH_TEST_LOG\"\nprintf '%s\\n' \"$OLLAMA_LAUNCH_DSH_API_KEY\" >> \"$DSH_TEST_LOG\"\n"
bin := filepath.Join(binDir, "dsh")
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", strings.Join([]string{binDir, "/bin", "/usr/bin"}, string(os.PathListSeparator)))
t.Setenv("DSH_TEST_LOG", logPath)
t.Setenv(deepSeekHarnessAPIKeyEnv, "do-not-keep")
dsh := &DeepSeekHarness{}
if err := dsh.Configure("qwen3.5"); err != nil {
t.Fatal(err)
}
if err := dsh.Run("qwen3.5", nil, []string{"--port", "0"}); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(logPath)
if err != nil {
t.Fatal(err)
}
patchPath, _ := deepSeekHarnessPatchPath()
want := "web\n--patch\n" + patchPath + "\n--port\n0\nollama\n"
if string(data) != want {
t.Fatalf("invocation = %q, want %q", data, want)
}
}
func TestDeepSeekHarnessRejectsManagedPatchArgument(t *testing.T) {
for _, args := range [][]string{{"--patch", "other.yml"}, {"--patch=other.yml"}} {
if err := (&DeepSeekHarness{}).Run("qwen3.5", nil, args); err == nil || !strings.Contains(err.Error(), "manages --patch") {
t.Fatalf("Run(%v) error = %v", args, err)
}
}
}
func TestEnsureDeepSeekHarnessInstalledUsesPublicNpmPackage(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX shell test binary")
}
home := t.TempDir()
binDir := t.TempDir()
logPath := filepath.Join(home, "npm-invocation")
npm := filepath.Join(binDir, "npm")
script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$DSH_NPM_LOG\"\nprintf '#!/bin/sh\\nexit 0\\n' > \"$DSH_INSTALLED_BIN\"\nchmod +x \"$DSH_INSTALLED_BIN\"\n"
if err := os.WriteFile(npm, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
dshBin := filepath.Join(binDir, "dsh")
t.Setenv("PATH", strings.Join([]string{binDir, "/bin", "/usr/bin"}, string(os.PathListSeparator)))
t.Setenv("DSH_NPM_LOG", logPath)
t.Setenv("DSH_INSTALLED_BIN", dshBin)
restore := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true})
defer restore()
path, err := ensureDeepSeekHarnessInstalled()
if err != nil {
t.Fatal(err)
}
if path != dshBin {
t.Fatalf("installed path = %q, want %q", path, dshBin)
}
data, err := os.ReadFile(logPath)
if err != nil {
t.Fatal(err)
}
if string(data) != "install\n-g\n@deepseek-ai/dsh@latest\n" {
t.Fatalf("npm invocation = %q", data)
}
}
func TestDeepSeekHarnessLaunchArgs(t *testing.T) {
got := deepSeekHarnessLaunchArgs("/tmp/ollama.cordis.yml", []string{"--port", "0"})
want := []string{"web", "--patch", "/tmp/ollama.cordis.yml", "--port", "0"}
if !slices.Equal(got, want) {
t.Fatalf("launch args = %v, want %v", got, want)
}
}
func TestDeepSeekHarnessWindowsNodeShims(t *testing.T) {
root := t.TempDir()
node := filepath.Join(root, "node.exe")
dsh := filepath.Join(root, "npm", "dsh.cmd")
npm := filepath.Join(root, "node", "npm.cmd")
dshEntrypoint := filepath.Join(filepath.Dir(dsh), "node_modules", "@deepseek-ai", "dsh", "lib", "bin.js")
npmEntrypoint := filepath.Join(filepath.Dir(npm), "node_modules", "npm", "bin", "npm-cli.js")
for _, path := range []string{node, dsh, npm, dshEntrypoint, npmEntrypoint} {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, nil, 0o755); err != nil {
t.Fatal(err)
}
}
originalGOOS := deepSeekHarnessGOOS
originalLookPath := deepSeekHarnessLookPath
deepSeekHarnessGOOS = "windows"
deepSeekHarnessLookPath = func(file string) (string, error) {
if file == "node" {
return node, nil
}
return "", exec.ErrNotFound
}
t.Cleanup(func() {
deepSeekHarnessGOOS = originalGOOS
deepSeekHarnessLookPath = originalLookPath
})
t.Run("dsh", func(t *testing.T) {
cmd, err := deepSeekHarnessExecutableCommand(dsh, []string{"web", "--port", "0", "a&b"})
if err != nil {
t.Fatal(err)
}
want := []string{node, dshEntrypoint, "web", "--port", "0", "a&b"}
if !slices.Equal(cmd.Args, want) {
t.Fatalf("command args = %v, want %v", cmd.Args, want)
}
})
t.Run("npm", func(t *testing.T) {
cmd, err := deepSeekHarnessNpmCommand(npm, []string{"install", "-g", deepSeekHarnessNpmPackage})
if err != nil {
t.Fatal(err)
}
want := []string{node, npmEntrypoint, "install", "-g", deepSeekHarnessNpmPackage}
if !slices.Equal(cmd.Args, want) {
t.Fatalf("command args = %v, want %v", cmd.Args, want)
}
})
}
func TestDeepSeekHarnessWindowsNodeShimRequiresEntrypoint(t *testing.T) {
originalGOOS := deepSeekHarnessGOOS
originalLookPath := deepSeekHarnessLookPath
deepSeekHarnessGOOS = "windows"
deepSeekHarnessLookPath = func(file string) (string, error) {
if file == "node" {
return filepath.Join(t.TempDir(), "node.exe"), nil
}
return "", exec.ErrNotFound
}
t.Cleanup(func() {
deepSeekHarnessGOOS = originalGOOS
deepSeekHarnessLookPath = originalLookPath
})
_, err := deepSeekHarnessExecutableCommand(filepath.Join(t.TempDir(), "dsh.cmd"), nil)
if err == nil || !strings.Contains(err.Error(), "resolve Windows entrypoint") {
t.Fatalf("error = %v", err)
}
}
func TestDeepSeekHarnessInstallDependencyError(t *testing.T) {
originalLookPath := deepSeekHarnessLookPath
deepSeekHarnessLookPath = func(file string) (string, error) { return "", exec.ErrNotFound }
t.Cleanup(func() { deepSeekHarnessLookPath = originalLookPath })
_, err := ensureDeepSeekHarnessInstalled()
if err == nil || !strings.Contains(err.Error(), "npm (Node.js) is required") {
t.Fatalf("error = %v", err)
}
}
+1 -1
View File
@@ -152,7 +152,7 @@ func (h *HermesDesktop) launchArgs(args []string) []string {
}
func (h *HermesDesktop) shouldSkipDesktopBuild(args []string) bool {
if hermesDesktopHasFlag(args, "--skip-build", "--source", "--build-only", "--help", "-h") {
if hermesDesktopHasFlag(args, "--skip-build", "--force-build", "--source", "--build-only", "--help", "-h") {
return false
}
return h.packagedAppExists()
+47
View File
@@ -349,6 +349,46 @@ func TestHermesConfigureUsesLaunchResolvedHostForModelDiscovery(t *testing.T) {
}
}
func TestHermesConfigurePreservesExplicitCloudModel(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
withHermesPlatform(t, "darwin")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/tags":
fmt.Fprint(w, `{"models":[{"name":"qwen3.5:cloud"},{"name":"gemma4"}]}`)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
withHermesOllamaURL(t, srv.URL)
if err := (&Hermes{}).Configure("qwen3.5:cloud"); err != nil {
t.Fatalf("Configure returned error: %v", err)
}
data, err := os.ReadFile(filepath.Join(tmpDir, ".hermes", "config.yaml"))
if err != nil {
t.Fatal(err)
}
var cfg map[string]any
if err := yaml.Unmarshal(data, &cfg); err != nil {
t.Fatalf("failed to parse rewritten yaml: %v", err)
}
modelCfg, _ := cfg["model"].(map[string]any)
if got, _ := modelCfg["default"].(string); got != "qwen3.5:cloud" {
t.Fatalf("expected explicit cloud model to be preserved, got %q", got)
}
providers, _ := cfg["providers"].(map[string]any)
provider, _ := providers[hermesProviderKey].(map[string]any)
if got, _ := provider["default_model"].(string); got != "qwen3.5:cloud" {
t.Fatalf("expected provider default model to be preserved, got %q", got)
}
}
func TestHermesConfigureMigratesLegacyManagedAliases(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
@@ -678,6 +718,13 @@ func TestHermesDesktopRun(t *testing.T) {
hasPackage: true,
want: "[desktop --skip-build]",
},
{
name: "force build",
goos: runtime.GOOS,
args: []string{"--force-build"},
hasPackage: true,
want: "[desktop --force-build]",
},
{
name: "source mode",
goos: runtime.GOOS,
+6 -2
View File
@@ -64,7 +64,11 @@ func TestIntegrationLookup(t *testing.T) {
{"codex app gui alias", "codex-gui", true, "ChatGPT"},
{"hermes desktop", "hermes-desktop", true, "Hermes Desktop"},
{"kimi", "kimi", true, "Kimi Code CLI"},
{"muse", "muse", true, "Muse Code"},
{"muse alias", "muse-code", true, "Muse Code"},
{"droid", "droid", true, "Droid"},
{"dsh", "dsh", true, "DeepSeek Harness"},
{"deepseek harness alias", "deepseek-harness", true, "DeepSeek Harness"},
{"opencode", "opencode", true, "OpenCode"},
{"omp", "omp", true, "OMP"},
{"pool", "pool", true, "Pool"},
@@ -86,7 +90,7 @@ func TestIntegrationLookup(t *testing.T) {
}
func TestIntegrationRegistry(t *testing.T) {
expectedIntegrations := []string{"claude", "claude-desktop", "cline", "codex", "chatgpt", "kimi", "droid", "opencode", "omp", "hermes", "hermes-desktop", "pool", "qwen"}
expectedIntegrations := []string{"claude", "claude-desktop", "cline", "codex", "chatgpt", "kimi", "muse", "droid", "dsh", "opencode", "omp", "hermes", "hermes-desktop", "pool", "qwen"}
for _, name := range expectedIntegrations {
t.Run(name, func(t *testing.T) {
r, ok := integrations[name]
@@ -127,7 +131,7 @@ func TestChatGPTMigratesLegacyCodexAppLaunchConfig(t *testing.T) {
func TestHiddenIntegrationsExcludedFromVisibleLists(t *testing.T) {
for _, info := range ListIntegrationInfos() {
switch info.Name {
case "vscode", "kimi":
case "vscode", "kimi", "muse":
t.Fatalf("hidden integration %q should not appear in ListIntegrationInfos", info.Name)
}
}
+3
View File
@@ -296,7 +296,9 @@ Supported integrations:
copilot Copilot CLI (aliases: copilot-cli)
omp OMP
droid Droid
dsh DeepSeek Harness (alias: deepseek-harness)
kimi Kimi Code CLI
muse Muse Code (aliases: muse-code)
pi Pi
pool Pool
cline Cline
@@ -311,6 +313,7 @@ Examples:
ollama launch chatgpt --restore
ollama launch hermes
ollama launch hermes-desktop
ollama launch dsh
ollama launch droid --config (does not auto-launch)
ollama launch codex --restore
ollama launch codex -- --sandbox workspace-write`,
+60
View File
@@ -151,6 +151,21 @@ func (r *launcherManagedListRunner) ConfigureWithModels(primary string, models [
return r.Configure(primary)
}
type launcherCanonicalManagedListRunner struct {
launcherManagedListRunner
}
func (r *launcherCanonicalManagedListRunner) ConfigureWithModels(primary string, models []LaunchModel) error {
r.configuredModelLists = append(r.configuredModelLists, launchModelNames(models))
r.configured = append(r.configured, primary)
if selected, ok := findLaunchModel(models, primary); ok {
r.currentModel = selected.Name
} else {
r.currentModel = primary
}
return nil
}
type launcherManagedAutodiscoveryRunner struct {
launcherManagedRunner
autodiscoveryConfigures int
@@ -1089,6 +1104,51 @@ func TestLaunchIntegration_ManagedSingleIntegrationCanConfigureWithModelList(t *
}
}
func TestLaunchIntegration_ManagedSingleIntegrationSavesCanonicalModel(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withInteractiveSession(t, true)
withLauncherHooks(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/experimental/model-recommendations":
fmt.Fprint(w, `{"recommendations":[]}`)
case "/api/tags":
fmt.Fprint(w, `{"models":[{"name":"qwen3.5:latest"}]}`)
case "/api/show":
fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
runner := &launcherCanonicalManagedListRunner{}
withIntegrationOverride(t, "stubmanaged", runner)
request := IntegrationLaunchRequest{Name: "stubmanaged", ModelOverride: "qwen3.5"}
if err := LaunchIntegration(context.Background(), request); err != nil {
t.Fatalf("first LaunchIntegration returned error: %v", err)
}
saved, err := config.LoadIntegration("stubmanaged")
if err != nil {
t.Fatalf("failed to reload managed integration config: %v", err)
}
if diff := compareStrings(saved.Models, []string{"qwen3.5:latest"}); diff != "" {
t.Fatalf("saved models mismatch: %s", diff)
}
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "stubmanaged"}); err != nil {
t.Fatalf("second LaunchIntegration returned error: %v", err)
}
if diff := compareStrings(runner.configured, []string{"qwen3.5"}); diff != "" {
t.Fatalf("expected second launch to skip configuration: %s", diff)
}
}
func TestLaunchIntegration_ManagedAutodiscoverySkipsModelPicker(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
+3
View File
@@ -323,6 +323,9 @@ func prepareManagedSingleIntegration(name string, managed ManagedSingleModel, mo
if err != nil {
return fmt.Errorf("setup failed: %w", err)
}
if current := managed.CurrentModel(); current != "" {
model = current
}
if err := config.SaveIntegration(name, []string{model}); err != nil {
return fmt.Errorf("failed to save: %w", err)
}
+403
View File
@@ -0,0 +1,403 @@
package launch
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"time"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/cmd/internal/fileutil"
"github.com/ollama/ollama/envconfig"
)
// Muse implements Runner and Editor for Meta's Muse Code CLI.
//
// Muse has no flag or environment variable that can supply a model catalog, and
// it will not start without one: its provider fetches <origin>/muse-code/models
// before the first inference call, reusing only the scheme, host and port of the
// configured base URL. The undocumented model_catalog key in settings.json is
// the one way to satisfy that without standing up the endpoint, so launch has to
// write a settings file.
//
// That file is also where the base URL lives, and endpoint_transport is a single
// global provider switch rather than an additive model list — writing it into
// ~/.config/muse/settings.json would repoint the user's whole muse install. So
// launch keeps its own config root and passes it to muse as XDG_CONFIG_HOME,
// leaving a Meta-backed muse and `ollama launch muse` free to coexist.
type Muse struct{}
const (
// Muse checks every catalog row against the session's provider and profile
// and drops the ones that disagree; "tbh" is the profile its sessions run
// under.
museProviderID = "meta"
museProfileID = "tbh"
// Every row needs a context and an output limit, and the inventory only
// reports an output limit for cloud models, so local models land on these.
museFallbackContextLimit = 32768
museFallbackOutputLimit = 32768
// museLoadTimeout bounds the default model's preload; a cold load of a
// large model is tens of seconds, and on timeout the row falls back to
// the inventory value rather than blocking the launch.
museLoadTimeout = 5 * time.Minute
museRowDescription = "Served by Ollama"
)
var museGOOS = runtime.GOOS
// museInstallCommand runs Meta's official installer, which places the muse
// launcher in ~/.local/bin and downloads the matching binary next to it.
var museInstallCommand = []string{"bash", "-c", "curl -fsSL https://dev.meta.ai/install.sh | bash"}
// ensureMuseInstalled returns the muse binary path, offering to run the
// official installer when it is missing and verifying the binary afterward.
func ensureMuseInstalled() (string, error) {
if path, err := findMuse(); err == nil {
return path, nil
}
ok, err := ConfirmPrompt("Muse is not installed. Install now?")
if err != nil {
return "", err
}
if !ok {
return "", fmt.Errorf("muse installation cancelled")
}
fmt.Fprintf(os.Stderr, "\nInstalling Muse...\n")
cmd := exec.Command(museInstallCommand[0], museInstallCommand[1:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("muse installation failed: %w", err)
}
path, err := findMuse()
if err != nil {
return "", fmt.Errorf("muse installer finished but the binary was not found")
}
return path, nil
}
// museCatalogRow is one row of muse's settings-side model catalog. Muse requires
// model_id, provider_id, profile_id and both limits; the rest only affect how
// the model reads in muse's picker.
type museCatalogRow struct {
ModelID string `json:"model_id"`
ProviderID string `json:"provider_id"`
ProfileID string `json:"profile_id"`
DisplayLabel string `json:"display_label"`
Visibility string `json:"visibility"`
DisplayOrder int `json:"display_order"`
IsDefault bool `json:"is_default"`
ContextLimit int `json:"context_limit"`
OutputLimit int `json:"output_limit"`
Description string `json:"description"`
}
func (m *Muse) String() string { return "Muse Code" }
func (m *Muse) Supported() error {
if museGOOS == "windows" {
return fmt.Errorf("Warning: Muse is not currently supported on Windows")
}
return nil
}
func (m *Muse) Run(model string, models []LaunchModel, args []string) error {
if err := m.Supported(); err != nil {
return err
}
if strings.TrimSpace(model) == "" {
return fmt.Errorf("model is required")
}
bin, err := ensureMuseInstalled()
if err != nil {
return err
}
runModels := museApplyLoadedContext(museRunModels(model, models))
// When Edit already wrote this catalog, Run only refreshes loaded limits.
// Preserve Edit's backup by avoiding a second backup for that refresh.
backup := !slices.Equal(m.Models(), launchModelNames(runModels))
if err := writeMuseSettingsFile(runModels, backup); err != nil {
return fmt.Errorf("failed to configure muse: %w", err)
}
configHome, err := museConfigHome()
if err != nil {
return err
}
cmd := exec.Command(bin, args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = append(os.Environ(), "XDG_CONFIG_HOME="+configHome)
return cmd.Run()
}
func (m *Muse) Edit(models []LaunchModel) error {
return writeMuseSettings(models)
}
func (m *Muse) Paths() []string {
settingsPath, err := museSettingsPath()
if err != nil {
return nil
}
if _, err := os.Stat(settingsPath); err != nil {
return nil
}
return []string{settingsPath}
}
func (m *Muse) Models() []string {
settingsPath, err := museSettingsPath()
if err != nil {
return nil
}
data, err := os.ReadFile(settingsPath)
if err != nil {
return nil
}
var settings struct {
ModelCatalog []museCatalogRow `json:"model_catalog"`
}
if err := json.Unmarshal(data, &settings); err != nil {
return nil
}
var models []string
for _, row := range settings.ModelCatalog {
if row.ModelID != "" {
models = append(models, row.ModelID)
}
}
return models
}
// findMuse locates the muse launcher, which installs itself into ~/.local/bin
// and is not always on PATH.
func findMuse() (string, error) {
if path, err := exec.LookPath("muse"); err == nil {
return path, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
fallback := filepath.Join(home, ".local", "bin", "muse")
if info, err := os.Stat(fallback); err != nil || info.IsDir() {
return "", fmt.Errorf("muse binary not found")
}
return fallback, nil
}
// museConfigHome is the XDG_CONFIG_HOME handed to muse at launch. Muse reads
// settings.json from $XDG_CONFIG_HOME/muse, so the file sits one level deeper.
func museConfigHome() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".ollama", "launch", "muse-config"), nil
}
func museSettingsPath() (string, error) {
configHome, err := museConfigHome()
if err != nil {
return "", err
}
return filepath.Join(configHome, "muse", "settings.json"), nil
}
// museUserSettingsPath is where muse keeps settings.json when it runs on its own.
func museUserSettingsPath() (string, error) {
if configHome := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); configHome != "" {
return filepath.Join(configHome, "muse", "settings.json"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".config", "muse", "settings.json"), nil
}
// museBaseSettings returns the document the generated config is layered onto.
//
// Muse persists its own settings back into whichever config root it was given,
// so once launch's file exists it becomes the base and everything muse recorded
// there survives the next launch. Before that, the user's own settings seed it so
// a first launch keeps their skills, hooks, MCP servers and TUI preferences.
// A launch-owned file that exists but cannot be parsed is an error: falling
// through would rewrite it and discard whatever muse persisted there.
func museBaseSettings() (map[string]any, error) {
if path, err := museSettingsPath(); err == nil {
settings, err := fileutil.ReadJSON(path)
switch {
case err == nil && settings != nil:
return settings, nil
case err != nil && !os.IsNotExist(err):
return nil, fmt.Errorf("read muse settings %s: %w", path, err)
}
}
if path, err := museUserSettingsPath(); err == nil {
if settings, err := fileutil.ReadJSON(path); err == nil && settings != nil {
return settings, nil
}
}
return map[string]any{}, nil
}
// writeMuseSettings regenerates the settings file launch owns, replacing only
// the keys that decide which provider and models muse talks to.
func writeMuseSettings(models []LaunchModel) error {
return writeMuseSettingsFile(models, true)
}
func writeMuseSettingsFile(models []LaunchModel, backup bool) error {
if len(models) == 0 {
return nil
}
settingsPath, err := museSettingsPath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil {
return err
}
settings, err := museBaseSettings()
if err != nil {
return err
}
if _, ok := settings["schema_version"]; !ok {
settings["schema_version"] = 1
}
settings["provider"] = museProviderID
settings["model"] = models[0].Name
settings["endpoint_transport"] = map[string]any{
"base_url": envconfig.ConnectableHost().String() + "/v1",
// Ollama wants no credential, and muse refuses to start on the default
// "bearer" unless one is configured.
"auth": "none",
}
settings["model_catalog"] = museCatalogRows(models)
data, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return err
}
if backup {
return fileutil.WriteWithBackup(settingsPath, data, "muse")
}
return os.WriteFile(settingsPath, data, 0o600)
}
func museCatalogRows(models []LaunchModel) []museCatalogRow {
rows := make([]museCatalogRow, 0, len(models))
for i, model := range models {
contextLimit := model.ContextLength
if contextLimit <= 0 {
contextLimit = museFallbackContextLimit
}
outputLimit := model.MaxOutputTokens
if outputLimit <= 0 {
outputLimit = min(contextLimit, museFallbackOutputLimit)
}
rows = append(rows, museCatalogRow{
ModelID: model.Name,
ProviderID: museProviderID,
ProfileID: museProfileID,
DisplayLabel: model.Name,
Visibility: "visible",
DisplayOrder: i,
IsDefault: i == 0,
ContextLimit: contextLimit,
OutputLimit: outputLimit,
Description: museRowDescription,
})
}
return rows
}
// museApplyLoadedContext overwrites the launched model's context length with
// the size the server actually loaded it at. Muse budgets prompt packing and
// compaction against the catalog row, and VRAM fit or server configuration may
// hold the loaded size below the model's trained maximum — which is all the
// inventory knows. Run-only on the selected model: editing the config must
// not load anything, and the other picker rows are not worth a load each.
func museApplyLoadedContext(models []LaunchModel) []LaunchModel {
if len(models) == 0 || models[0].Remote {
return models
}
models = cloneLaunchModels(models)
if n := museLoadedContextLength(models[0].Name); n > 0 {
models[0].ContextLength = n
}
return models
}
// museLoadedContextLength reports the effective context length of a model as
// the server loaded it, swappable so tests never touch a live server.
var museLoadedContextLength = loadedContextLength
// loadedContextLength loads model and reads the running instance's context
// length from the process list — the size the scheduler actually allocated.
// An empty generate request is ollama's load-only call: it returns once the
// model is resident without generating tokens, and the launch pays a load the
// first muse request would otherwise pay. Returns 0 when anything fails, and
// the caller keeps the inventory value.
func loadedContextLength(model string) int {
client, err := api.ClientFromEnvironment()
if err != nil {
return 0
}
ctx, cancel := context.WithTimeout(context.Background(), museLoadTimeout)
defer cancel()
if err := client.Generate(ctx, &api.GenerateRequest{Model: model}, func(api.GenerateResponse) error { return nil }); err != nil {
return 0
}
return LoadedContextWindow(ctx, client, model)
}
// museRunModels puts the model being launched first, since muse takes its
// default from the first visible row, and keeps the rest of the selection
// available in muse's picker.
func museRunModels(primary string, models []LaunchModel) []LaunchModel {
resolved := make([]LaunchModel, 0, len(models)+1)
appendModel := func(name string) {
if name == "" || hasLaunchModel(resolved, name) {
return
}
if model, ok := findLaunchModel(models, name); ok {
resolved = append(resolved, model)
return
}
resolved = append(resolved, fallbackLaunchModel(name))
}
appendModel(primary)
for _, model := range models {
appendModel(model.Name)
}
return resolved
}
+633
View File
@@ -0,0 +1,633 @@
package launch
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/ollama/ollama/cmd/internal/fileutil"
)
// museGeneratedSettings is the launch-owned view of the file muse reads.
type museGeneratedSettings struct {
SchemaVersion int `json:"schema_version"`
Provider string `json:"provider"`
Model string `json:"model"`
Transport struct {
BaseURL string `json:"base_url"`
Auth string `json:"auth"`
} `json:"endpoint_transport"`
ModelCatalog []museCatalogRow `json:"model_catalog"`
MCPServers map[string]any `json:"mcp_servers"`
TUI map[string]any `json:"tui"`
}
func readMuseSettings(t *testing.T) museGeneratedSettings {
t.Helper()
path, err := museSettingsPath()
if err != nil {
t.Fatalf("museSettingsPath() error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("failed to read generated settings: %v", err)
}
var settings museGeneratedSettings
if err := json.Unmarshal(data, &settings); err != nil {
t.Fatalf("generated settings is not valid JSON: %v", err)
}
return settings
}
// stubMuseLoadedContext pins the default model's loaded-context probe so tests
// never load a model on a live server; 0 means "probe failed, keep inventory".
func stubMuseLoadedContext(t *testing.T, n int) {
t.Helper()
prev := museLoadedContextLength
museLoadedContextLength = func(string) int { return n }
t.Cleanup(func() { museLoadedContextLength = prev })
}
func TestMuseWriteSettings_BuildsCatalog(t *testing.T) {
setTestHome(t, t.TempDir())
t.Setenv("OLLAMA_HOST", "127.0.0.1:11434")
models := []LaunchModel{
{Name: "gpt-oss:20b", ContextLength: 131072, MaxOutputTokens: 32768},
{Name: "qwen3:8b"},
}
if err := writeMuseSettings(models); err != nil {
t.Fatalf("writeMuseSettings() error = %v", err)
}
settings := readMuseSettings(t)
if settings.Model != "gpt-oss:20b" {
t.Errorf("model = %q, want gpt-oss:20b", settings.Model)
}
if settings.Provider != museProviderID {
t.Errorf("provider = %q, want %q", settings.Provider, museProviderID)
}
if want := "http://127.0.0.1:11434/v1"; settings.Transport.BaseURL != want {
t.Errorf("base_url = %q, want %q", settings.Transport.BaseURL, want)
}
// Anything but "none" makes muse demand a credential it will never need.
if settings.Transport.Auth != "none" {
t.Errorf("auth = %q, want none", settings.Transport.Auth)
}
if len(settings.ModelCatalog) != 2 {
t.Fatalf("model_catalog has %d rows, want 2", len(settings.ModelCatalog))
}
first := settings.ModelCatalog[0]
if first.ModelID != "gpt-oss:20b" || first.DisplayOrder != 0 || !first.IsDefault {
t.Errorf("first row = %+v, want gpt-oss:20b as the default row", first)
}
if first.ContextLimit != 131072 || first.OutputLimit != 32768 {
t.Errorf("first row limits = %d/%d, want 131072/32768", first.ContextLimit, first.OutputLimit)
}
second := settings.ModelCatalog[1]
if second.ModelID != "qwen3:8b" || second.DisplayOrder != 1 || second.IsDefault {
t.Errorf("second row = %+v, want qwen3:8b as a non-default row", second)
}
if second.ContextLimit != museFallbackContextLimit || second.OutputLimit != museFallbackOutputLimit {
t.Errorf("second row limits = %d/%d, want the fallbacks %d/%d",
second.ContextLimit, second.OutputLimit, museFallbackContextLimit, museFallbackOutputLimit)
}
// Rows that disagree with the session's provider or profile are dropped by
// muse, which then falls back to a catalog fetch Ollama cannot serve.
for _, row := range settings.ModelCatalog {
if row.ProviderID != museProviderID {
t.Errorf("row %q provider_id = %q, want %q", row.ModelID, row.ProviderID, museProviderID)
}
if row.ProfileID != museProfileID {
t.Errorf("row %q profile_id = %q, want %q", row.ModelID, row.ProfileID, museProfileID)
}
if row.Visibility != "visible" {
t.Errorf("row %q visibility = %q, want visible", row.ModelID, row.Visibility)
}
}
}
func TestMuseWriteSettings_ClampsOutputLimitToContext(t *testing.T) {
setTestHome(t, t.TempDir())
if err := writeMuseSettings([]LaunchModel{{Name: "tiny:1b", ContextLength: 4096}}); err != nil {
t.Fatalf("writeMuseSettings() error = %v", err)
}
row := readMuseSettings(t).ModelCatalog[0]
if row.ContextLimit != 4096 || row.OutputLimit != 4096 {
t.Errorf("limits = %d/%d, want 4096/4096", row.ContextLimit, row.OutputLimit)
}
}
// TestMuseApplyLoadedContext pins the property that the launched model's row
// carries the context the server actually loaded it with, not the trained
// maximum from the inventory: muse budgets prompt packing and compaction
// against this row, and the loaded size is what requests really get.
func TestMuseApplyLoadedContext(t *testing.T) {
setTestHome(t, t.TempDir())
stubMuseLoadedContext(t, 8192)
models := []LaunchModel{
{Name: "gpt-oss:20b", ContextLength: 131072},
{Name: "qwen3:8b", ContextLength: 131072},
}
if err := writeMuseSettings(museApplyLoadedContext(models)); err != nil {
t.Fatalf("writeMuseSettings() error = %v", err)
}
rows := readMuseSettings(t).ModelCatalog
if rows[0].ContextLimit != 8192 || rows[0].OutputLimit != 8192 {
t.Errorf("default row limits = %d/%d, want the loaded 8192/8192", rows[0].ContextLimit, rows[0].OutputLimit)
}
// Only the launched model is preloaded; other rows keep inventory values.
if rows[1].ContextLimit != 131072 {
t.Errorf("second row context = %d, want the inventory 131072", rows[1].ContextLimit)
}
// The probe result lands in a copy, not the caller's slice.
if models[0].ContextLength != 131072 {
t.Errorf("caller's model mutated to ContextLength=%d", models[0].ContextLength)
}
}
// TestMuseApplyLoadedContext_SkipsRemoteAndEmpty: a remote (cloud) launch
// target must not be preloaded, and an empty selection passes through.
func TestMuseApplyLoadedContext_SkipsRemoteAndEmpty(t *testing.T) {
prev := museLoadedContextLength
museLoadedContextLength = func(string) int {
t.Fatal("probe must not run for a remote model")
return 0
}
t.Cleanup(func() { museLoadedContextLength = prev })
models := museApplyLoadedContext([]LaunchModel{{Name: "big:cloud", Remote: true, ContextLength: 65536}})
if models[0].ContextLength != 65536 {
t.Errorf("remote model context = %d, want untouched 65536", models[0].ContextLength)
}
if got := museApplyLoadedContext(nil); got != nil {
t.Errorf("nil models = %v, want nil", got)
}
}
func TestMuseWriteSettings_KeepsUserPreferences(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
t.Setenv("XDG_CONFIG_HOME", "")
userSettings := map[string]any{
"schema_version": 2,
"provider": "echo",
"model": "muse-large",
"endpoint_transport": map[string]any{
"base_url": "https://api.meta.ai/v1",
"auth": "bearer",
},
"mcp_servers": map[string]any{"github": map[string]any{"transport": "stdio"}},
"tui": map[string]any{"theme": "dark"},
}
userPath := filepath.Join(home, ".config", "muse", "settings.json")
if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil {
t.Fatal(err)
}
data, err := json.Marshal(userSettings)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(userPath, data, 0o644); err != nil {
t.Fatal(err)
}
if err := writeMuseSettings([]LaunchModel{{Name: "gpt-oss:20b"}}); err != nil {
t.Fatalf("writeMuseSettings() error = %v", err)
}
settings := readMuseSettings(t)
if settings.MCPServers["github"] == nil {
t.Error("mcp_servers was not carried over from the user's settings")
}
if settings.TUI["theme"] != "dark" {
t.Errorf("tui.theme = %v, want dark", settings.TUI["theme"])
}
if settings.SchemaVersion != 2 {
t.Errorf("schema_version = %d, want the user's 2", settings.SchemaVersion)
}
if settings.Provider != museProviderID {
t.Errorf("provider = %q, want it replaced with %q", settings.Provider, museProviderID)
}
if settings.Model != "gpt-oss:20b" {
t.Errorf("model = %q, want gpt-oss:20b", settings.Model)
}
if strings.Contains(settings.Transport.BaseURL, "meta.ai") {
t.Errorf("base_url = %q, want it repointed at Ollama", settings.Transport.BaseURL)
}
// The user's own settings must be left exactly as they were.
after, err := os.ReadFile(userPath)
if err != nil {
t.Fatal(err)
}
if string(after) != string(data) {
t.Errorf("user settings were modified:\n got: %s\nwant: %s", after, data)
}
}
func TestMuseWriteSettings_KeepsWhatMusePersisted(t *testing.T) {
setTestHome(t, t.TempDir())
if err := writeMuseSettings([]LaunchModel{{Name: "gpt-oss:20b"}}); err != nil {
t.Fatalf("writeMuseSettings() error = %v", err)
}
// Muse writes its own settings back into the config root it was handed.
settingsPath, err := museSettingsPath()
if err != nil {
t.Fatal(err)
}
settings, err := fileutil.ReadJSON(settingsPath)
if err != nil {
t.Fatal(err)
}
settings["tui"] = map[string]any{"foreign_context_notice_shown": true}
data, err := json.Marshal(settings)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(settingsPath, data, 0o644); err != nil {
t.Fatal(err)
}
if err := writeMuseSettings([]LaunchModel{{Name: "qwen3:8b"}}); err != nil {
t.Fatalf("writeMuseSettings() error = %v", err)
}
got := readMuseSettings(t)
if got.TUI["foreign_context_notice_shown"] != true {
t.Errorf("tui = %v, want muse's persisted settings kept", got.TUI)
}
if got.Model != "qwen3:8b" {
t.Errorf("model = %q, want qwen3:8b", got.Model)
}
if diff := compareStrings(museCatalogModelIDs(got.ModelCatalog), []string{"qwen3:8b"}); diff != "" {
t.Errorf("model_catalog mismatch: %s", diff)
}
}
func museCatalogModelIDs(rows []museCatalogRow) []string {
ids := make([]string, 0, len(rows))
for _, row := range rows {
ids = append(ids, row.ModelID)
}
return ids
}
func TestMuseRunModels(t *testing.T) {
selection := []LaunchModel{
{Name: "gpt-oss:20b"},
{Name: "qwen3:8b"},
}
tests := []struct {
name string
primary string
models []LaunchModel
want []string
}{
{
name: "primary already first",
primary: "gpt-oss:20b",
models: selection,
want: []string{"gpt-oss:20b", "qwen3:8b"},
},
{
name: "primary moves to front",
primary: "qwen3:8b",
models: selection,
want: []string{"qwen3:8b", "gpt-oss:20b"},
},
{
name: "primary outside the selection",
primary: "llama3.2",
models: selection,
want: []string{"llama3.2", "gpt-oss:20b", "qwen3:8b"},
},
{
name: "no selection",
primary: "llama3.2",
want: []string{"llama3.2"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := launchModelNames(museRunModels(tt.primary, tt.models))
if diff := compareStrings(got, tt.want); diff != "" {
t.Errorf("museRunModels(%q) mismatch: %s", tt.primary, diff)
}
})
}
}
func TestMusePathsAndModels(t *testing.T) {
setTestHome(t, t.TempDir())
m := &Muse{}
if paths := m.Paths(); paths != nil {
t.Errorf("Paths() before configuring = %v, want nil", paths)
}
if models := m.Models(); models != nil {
t.Errorf("Models() before configuring = %v, want nil", models)
}
if err := m.Edit([]LaunchModel{{Name: "gpt-oss:20b"}, {Name: "qwen3:8b"}}); err != nil {
t.Fatalf("Edit() error = %v", err)
}
settingsPath, err := museSettingsPath()
if err != nil {
t.Fatal(err)
}
if diff := compareStrings(m.Paths(), []string{settingsPath}); diff != "" {
t.Errorf("Paths() mismatch: %s", diff)
}
if diff := compareStrings(m.Models(), []string{"gpt-oss:20b", "qwen3:8b"}); diff != "" {
t.Errorf("Models() mismatch: %s", diff)
}
}
func TestMuseRun_PointsMuseAtLaunchConfig(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell fake binary")
}
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
stubMuseLoadedContext(t, 0)
t.Setenv("OLLAMA_HOST", "127.0.0.1:11434")
logPath := filepath.Join(tmpDir, "muse-invocation.log")
script := fmt.Sprintf(`#!/bin/sh
printf "%%s\n" "$XDG_CONFIG_HOME" >> %q
for arg in "$@"; do
printf "%%s\n" "$arg" >> %q
done
exit 0
`, logPath, logPath)
if err := os.WriteFile(filepath.Join(tmpDir, "muse"), []byte(script), 0o755); err != nil {
t.Fatalf("failed to write fake muse: %v", err)
}
t.Setenv("PATH", tmpDir)
m := &Muse{}
if err := m.Run("qwen3:8b", testLaunchModels("gpt-oss:20b", "qwen3:8b"), []string{"--trust-workspace"}); err != nil {
t.Fatalf("Run() error = %v", err)
}
data, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("failed to read invocation log: %v", err)
}
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
if len(lines) != 2 {
t.Fatalf("invocation log = %v, want the config home and one arg", lines)
}
configHome, err := museConfigHome()
if err != nil {
t.Fatal(err)
}
if lines[0] != configHome {
t.Errorf("XDG_CONFIG_HOME = %q, want %q", lines[0], configHome)
}
if lines[1] != "--trust-workspace" {
t.Errorf("extra args = %v, want [--trust-workspace]", lines[1:])
}
// Run configures muse itself, so the launched model leads the catalog even
// when Edit never ran.
settings := readMuseSettings(t)
if settings.Model != "qwen3:8b" {
t.Errorf("model = %q, want qwen3:8b", settings.Model)
}
if len(settings.ModelCatalog) != 2 || !settings.ModelCatalog[0].IsDefault ||
settings.ModelCatalog[0].ModelID != "qwen3:8b" {
t.Errorf("model_catalog = %+v, want qwen3:8b first and default", settings.ModelCatalog)
}
}
func TestMuseRun_PreservesPreEditBackup(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell fake binary")
}
home := t.TempDir()
setTestHome(t, home)
stubMuseLoadedContext(t, 8192)
writeFakeBinary(t, home, "muse")
t.Setenv("PATH", home)
settingsPath, err := museSettingsPath()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil {
t.Fatal(err)
}
original := []byte(`{"schema_version":1,"mcp_servers":{"original":{"transport":"stdio"}}}`)
if err := os.WriteFile(settingsPath, original, 0o644); err != nil {
t.Fatal(err)
}
m := &Muse{}
models := []LaunchModel{{Name: "gpt-oss:20b", ContextLength: 131072}}
if err := m.Edit(models); err != nil {
t.Fatalf("Edit() error = %v", err)
}
if err := m.Run("gpt-oss:20b", models, nil); err != nil {
t.Fatalf("Run() error = %v", err)
}
settings := readMuseSettings(t)
if settings.ModelCatalog[0].ContextLimit != 8192 {
t.Fatalf("context limit = %d, want loaded context 8192", settings.ModelCatalog[0].ContextLimit)
}
backups, err := filepath.Glob(filepath.Join(fileutil.BackupDir(), "muse", "settings.json.*"))
if err != nil {
t.Fatal(err)
}
if len(backups) != 1 {
t.Fatalf("backup count = %d, want 1", len(backups))
}
data, err := os.ReadFile(backups[0])
if err != nil {
t.Fatal(err)
}
if string(data) != string(original) {
t.Fatalf("pre-Edit settings backup = %s, want %s", data, original)
}
}
func TestMuseRun_RequiresModel(t *testing.T) {
setTestHome(t, t.TempDir())
if err := (&Muse{}).Run("", nil, nil); err == nil {
t.Error("Run() without a model = nil, want an error")
}
}
func TestMuseSupported(t *testing.T) {
oldGOOS := museGOOS
t.Cleanup(func() { museGOOS = oldGOOS })
m := &Muse{}
for _, goos := range []string{"darwin", "linux"} {
museGOOS = goos
if err := m.Supported(); err != nil {
t.Errorf("Supported() on %s = %v, want nil", goos, err)
}
}
museGOOS = "windows"
if err := m.Supported(); err == nil {
t.Error("Supported() on windows = nil, want an error")
}
}
func TestMuseBaseSettings_MalformedLaunchFileFails(t *testing.T) {
setTestHome(t, t.TempDir())
settingsPath, err := museSettingsPath()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil {
t.Fatal(err)
}
malformed := []byte("{ this is not json")
if err := os.WriteFile(settingsPath, malformed, 0o644); err != nil {
t.Fatal(err)
}
if err := writeMuseSettings([]LaunchModel{{Name: "gpt-oss:20b"}}); err == nil {
t.Fatal("writeMuseSettings() = nil, want parse error for malformed launch-owned settings")
}
data, err := os.ReadFile(settingsPath)
if err != nil {
t.Fatal(err)
}
if string(data) != string(malformed) {
t.Fatalf("malformed settings were rewritten to %q", data)
}
}
func TestEnsureMuseInstalled(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Muse is not supported on Windows")
}
withConfirm := func(t *testing.T, fn func(prompt string) (bool, error)) {
t.Helper()
oldConfirm := DefaultConfirmPrompt
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
return fn(prompt)
}
t.Cleanup(func() { DefaultConfirmPrompt = oldConfirm })
}
stubInstaller := func(t *testing.T, script string) {
t.Helper()
oldCommand := museInstallCommand
// Absolute shell path and explicit PATH: these tests clear PATH to
// hide any real muse, which also hides the script's own utilities.
museInstallCommand = []string{"/bin/sh", "-c", "PATH=/usr/bin:/bin; " + script}
t.Cleanup(func() { museInstallCommand = oldCommand })
}
t.Run("already installed skips prompt", func(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
t.Setenv("PATH", t.TempDir())
binDir := filepath.Join(home, ".local", "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatal(err)
}
writeFakeBinary(t, binDir, "muse")
withConfirm(t, func(prompt string) (bool, error) {
t.Fatalf("did not expect prompt, got %q", prompt)
return false, nil
})
bin, err := ensureMuseInstalled()
if err != nil {
t.Fatalf("ensureMuseInstalled() error = %v", err)
}
if bin != filepath.Join(binDir, "muse") {
t.Fatalf("bin = %q, want %q", bin, filepath.Join(binDir, "muse"))
}
})
t.Run("installs after confirmation and verifies binary", func(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
t.Setenv("PATH", t.TempDir())
binDir := filepath.Join(home, ".local", "bin")
stubInstaller(t, fmt.Sprintf("mkdir -p %q && printf '#!/bin/sh\n' > %q && chmod +x %q",
binDir, filepath.Join(binDir, "muse"), filepath.Join(binDir, "muse")))
prompted := false
withConfirm(t, func(prompt string) (bool, error) {
prompted = true
return true, nil
})
bin, err := ensureMuseInstalled()
if err != nil {
t.Fatalf("ensureMuseInstalled() error = %v", err)
}
if !prompted {
t.Fatal("expected an install confirmation prompt")
}
if bin != filepath.Join(binDir, "muse") {
t.Fatalf("bin = %q, want %q", bin, filepath.Join(binDir, "muse"))
}
})
t.Run("declined prompt cancels", func(t *testing.T) {
setTestHome(t, t.TempDir())
t.Setenv("PATH", t.TempDir())
stubInstaller(t, "exit 0")
withConfirm(t, func(prompt string) (bool, error) {
return false, nil
})
if _, err := ensureMuseInstalled(); err == nil {
t.Fatal("ensureMuseInstalled() = nil, want cancellation error")
}
})
t.Run("installer without binary fails verification", func(t *testing.T) {
setTestHome(t, t.TempDir())
t.Setenv("PATH", t.TempDir())
stubInstaller(t, "exit 0")
withConfirm(t, func(prompt string) (bool, error) {
return true, nil
})
if _, err := ensureMuseInstalled(); err == nil {
t.Fatal("ensureMuseInstalled() = nil, want verification error")
}
})
}
+37 -1
View File
@@ -33,7 +33,7 @@ type IntegrationInfo struct {
Description string
}
var launcherIntegrationOrder = []string{"claude", "chatgpt", "hermes", "openclaw", "opencode", "hermes-desktop", "codex", "copilot", "omp", "cline", "droid", "pi", "pool", "qwen"}
var launcherIntegrationOrder = []string{"claude", "chatgpt", "hermes", "openclaw", "opencode", "hermes-desktop", "codex", "copilot", "omp", "cline", "droid", "dsh", "pi", "pool", "qwen"}
var integrationSpecs = []*IntegrationSpec{
{
@@ -123,6 +123,24 @@ var integrationSpecs = []*IntegrationSpec{
URL: "https://moonshotai.github.io/kimi-cli/en/guides/getting-started.html",
},
},
{
Name: "muse",
Runner: &Muse{},
Aliases: []string{"muse-code"},
Description: "Meta's agentic coding CLI",
Hidden: true,
Install: IntegrationInstallSpec{
CheckInstalled: func() bool {
_, err := findMuse()
return err == nil
},
EnsureInstalled: func() error {
_, err := ensureMuseInstalled()
return err
},
Command: museInstallCommand,
},
},
{
Name: "copilot",
Runner: &Copilot{},
@@ -148,6 +166,24 @@ var integrationSpecs = []*IntegrationSpec{
URL: "https://docs.factory.ai/cli/getting-started/quickstart",
},
},
{
Name: deepSeekHarnessIntegrationName,
Runner: &DeepSeekHarness{},
Aliases: []string{"deepseek-harness"},
Description: "DeepSeek's open-source agent harness",
Install: IntegrationInstallSpec{
CheckInstalled: func() bool {
_, err := deepSeekHarnessLookPath("dsh")
return err == nil
},
EnsureInstalled: func() error {
_, err := ensureDeepSeekHarnessInstalled()
return err
},
URL: "https://github.com/deepseek-ai/deepseek-harness",
Command: []string{"npm", "install", "-g", deepSeekHarnessNpmPackage},
},
},
{
Name: "opencode",
Runner: &OpenCode{},
+74 -5
View File
@@ -2,6 +2,7 @@ package chat
import (
"context"
"encoding/json"
"fmt"
"slices"
"strings"
@@ -263,11 +264,8 @@ func approvalToolCallDetail(call coreagent.ApprovalToolCall, width int) string {
}
var lines []string
lines = append(lines, "path: "+path)
if oldText, ok := rawStringArg(call.Args, "old_text"); ok {
lines = append(lines, fmt.Sprintf("old_text: %d chars", len([]rune(oldText))))
}
if newText, ok := rawStringArg(call.Args, "new_text"); ok {
lines = append(lines, fmt.Sprintf("new_text: %d chars", len([]rune(newText))))
if summary := editApprovalSummary(call.Args); summary != "" {
lines = append(lines, strings.Split(summary, "\n")...)
}
return chatMetaStyle.Render(strings.Join(lines, "\n"))
default:
@@ -278,6 +276,77 @@ func approvalToolCallDetail(call coreagent.ApprovalToolCall, width int) string {
}
}
// editApprovalSummary summarizes edit tool arguments for the approval dialog:
// the edit count and total sizes for the edits array form, or the legacy
// top-level old_text/new_text sizes.
func editApprovalSummary(args map[string]any) string {
edits := rawEditEntries(args)
if len(edits) > 0 {
oldChars, newChars := 0, 0
for _, entry := range edits {
if oldText, ok := editEntryText(entry, "old_text"); ok {
oldChars += len([]rune(oldText))
}
if newText, ok := editEntryText(entry, "new_text"); ok {
newChars += len([]rune(newText))
}
}
return fmt.Sprintf("edits: %d (old: %d chars, new: %d chars)", len(edits), oldChars, newChars)
}
if raw, ok := args["edits"]; ok {
if s, ok := raw.(string); ok {
return fmt.Sprintf("edits: %d chars", len([]rune(s)))
}
}
var lines []string
if oldText, ok := rawStringArg(args, "old_text"); ok {
lines = append(lines, fmt.Sprintf("old_text: %d chars", len([]rune(oldText))))
}
if newText, ok := rawStringArg(args, "new_text"); ok {
lines = append(lines, fmt.Sprintf("new_text: %d chars", len([]rune(newText))))
}
return strings.Join(lines, "\n")
}
// rawEditEntries extracts the edits array entries from tool arguments,
// tolerating a JSON string encoding and camelCase keys.
func rawEditEntries(args map[string]any) []map[string]any {
raw, ok := args["edits"]
if !ok {
return nil
}
if s, ok := raw.(string); ok {
var decoded []map[string]any
if err := json.Unmarshal([]byte(s), &decoded); err != nil {
return nil
}
return decoded
}
items, ok := raw.([]any)
if !ok {
return nil
}
entries := make([]map[string]any, 0, len(items))
for _, item := range items {
if entry, ok := item.(map[string]any); ok {
entries = append(entries, entry)
}
}
return entries
}
func editEntryText(entry map[string]any, snake string) (string, bool) {
if value, ok := entry[snake].(string); ok {
return value, true
}
camel := strings.TrimSuffix(snake, "_text") + "Text"
if value, ok := entry[camel].(string); ok {
return value, true
}
return "", false
}
func renderApprovalChoices(request coreagent.ApprovalRequest, cursor int, width int) []string {
var lines []string
for i, choice := range chatApprovalChoices {
+25
View File
@@ -134,6 +134,31 @@ func TestChatApprovalUsesShellNameForPermissionPrompt(t *testing.T) {
}
}
func TestChatApprovalRendersSkillLoad(t *testing.T) {
request := coreagent.ApprovalRequest{
WorkingDir: "/repo",
Calls: []coreagent.ApprovalToolCall{{
ToolCallID: "call-skill-1",
ToolName: "skill",
Args: map[string]any{"name": "release-notes"},
ApprovalScope: "skill",
}},
}
lines := stripANSI(strings.Join((&chatModel{approvalPrompt: &chatApprovalPrompt{request: request}}).renderApprovalPromptLines(80), "\n"))
for _, want := range []string{"name: release-notes", "2. Always allow skill"} {
if !strings.Contains(lines, want) {
t.Fatalf("skill approval prompt missing %q:\n%s", want, lines)
}
}
m := chatModel{}
m.upsertApprovalToolEntries(request)
if len(m.entries) != 1 || !strings.Contains(stripANSI(toolStatusLine(m.entries[0])), `skill("release-notes") needs approval`) {
t.Fatalf("skill approval entry = %#v", m.entries)
}
}
func TestChatApprovalPromptOmitsDuplicateBatchDetails(t *testing.T) {
request := coreagent.ApprovalRequest{
WorkingDir: "/repo",
+67 -47
View File
@@ -55,6 +55,8 @@ type Options struct {
Client coreagent.ChatClient
Tools *coreagent.Registry
Skills *coreagent.SkillCatalog
ImportSkills func(string) (coreagent.SkillImportResult, error)
ReloadSkills func() (*coreagent.SkillCatalog, error)
ToolRegistryForModel func(context.Context, string) *coreagent.Registry
ToolsDisabled bool
MultiModalForModel func(context.Context, string) bool
@@ -98,46 +100,50 @@ type chatModel struct {
entries []chatEntry
workingDir string
input []rune
inputCursor int
inputCursorSet bool
inputAttachments []chatInputAttachment
inputPastedTexts []chatInputPastedText
nextImageID int
nextAudioID int
nextPastedTextID int
promptHistory []string
promptCursor int
promptDraft []rune
promptActive bool
running bool
awaitingModel bool
compacting bool
cancel context.CancelFunc
events <-chan tea.Msg
compactEvents <-chan tea.Msg
detectedToolCalls []chatEntry
scroll int
toolOutputMode bool
toolOutputOpen bool
flowPrintedLines int
thinking bool
thinkingTokens int
compactingTokens int
contextTokens int
contextEstimate bool
modelPicker *chatModelPicker
modelPickerModels []ModelOption
thinkPicker *chatThinkPicker
promptDebug *chatPromptDebug
approvalPrompt *chatApprovalPrompt
approvalController *chatApprovalController
approvalState *coreagent.ApprovalState
cloudAuthPrompt *cloudAuthPrompt
pendingModel string
defaultAllowAll bool
permissionNotice string
selection chatSelection
input []rune
inputCursor int
inputCursorSet bool
inputAttachments []chatInputAttachment
inputPastedTexts []chatInputPastedText
nextImageID int
nextAudioID int
nextPastedTextID int
promptHistory []string
promptCursor int
promptDraft []rune
promptActive bool
running bool
awaitingModel bool
compacting bool
cancel context.CancelFunc
events <-chan tea.Msg
compactEvents <-chan tea.Msg
detectedToolCalls []chatEntry
scroll int
toolOutputMode bool
toolOutputOpen bool
thinkingDetailsOpen bool
flowPrintedLines int
thinking bool
thinkingPhaseStart int
thinkingTokens int
compactingTokens int
contextTokens int
contextEstimate bool
modelPicker *chatModelPicker
modelPickerModels []ModelOption
thinkPicker *chatThinkPicker
promptDebug *chatPromptDebug
approvalPrompt *chatApprovalPrompt
approvalController *chatApprovalController
approvalState *coreagent.ApprovalState
cloudAuthPrompt *cloudAuthPrompt
pendingModel string
defaultAllowAll bool
permissionNotice string
selection chatSelection
systemPromptDisabled bool
width int
height int
@@ -224,9 +230,11 @@ func Run(ctx context.Context, opts Options) (*Result, error) {
m.nextImageID, m.nextAudioID = nextInputAttachmentIDsFromMessages(m.messages)
m.nextPastedTextID = nextInputPastedTextIDFromMessages(m.messages)
m.entries = entriesFromMessages(m.messages)
if !m.openModelOnInit {
m.refreshContextWindowTokens(m.opts.Model)
}
// Context window is resolved post-load (chatModelPreloadDoneMsg) rather than
// here: for local models /api/ps only reports the running num_ctx after the
// model loads, and opts.ContextWindowTokens already holds Show's max as a
// pre-load fallback. Refreshing now would just re-derive that same value
// (and block construction on a network call).
m.contextTokens = m.estimatePromptTokens(m.messages, "")
m.contextEstimate = true
if m.openModelOnInit {
@@ -375,7 +383,8 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.result.WorkingDir != "" {
m.workingDir = msg.result.WorkingDir
}
m.refreshContextWindowTokens(m.responseModelName(&msg.result.Latest))
// Context window is settled by preload (local num_ctx) or is
// static (cloud); no refresh needed post-run.
m.contextTokens = m.estimatePromptTokens(m.messages, "")
m.contextEstimate = true
if !messagesEndWithCompactionResult(m.messages) {
@@ -550,7 +559,7 @@ func (m chatModel) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.updatePromptDebug(msg)
}
if msg.Type == tea.KeyCtrlO {
m.toggleInlineToolOutput()
m.toggleInlineTranscriptDetails()
m.disarmQuit()
m.disarmEsc()
return m.withFlowTranscriptRepaint(nil)
@@ -589,6 +598,12 @@ func (m chatModel) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.insertInputNewline()
return m, nil
}
if m.applySlashCompletion() {
return m, nil
}
if m.applyMentionCompletion() {
return m, nil
}
return m.handleSubmit()
case tea.KeyCtrlJ:
m.insertInputNewline()
@@ -670,10 +685,12 @@ func (m chatModel) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m *chatModel) toggleInlineToolOutput() {
func (m *chatModel) toggleInlineTranscriptDetails() {
m.toolOutputMode = true
m.toolOutputOpen = !m.toolOutputOpen
m.applyToolOutputMode()
m.thinkingDetailsOpen = !m.thinkingDetailsOpen
m.applyThinkingDetails()
m.selection = chatSelection{}
m.scroll = 0
}
@@ -955,6 +972,10 @@ func (m chatModel) flowTranscriptHoldEntryIndex() int {
if isToolActiveStatus(entry.status) {
return index
}
case "thinking":
if entry.status == "running" {
return index
}
}
return -1
}
@@ -1093,7 +1114,6 @@ func (m *chatModel) startSkillRun(name, prompt string) (tea.Model, tea.Cmd) {
}
func (m *chatModel) startRunWithMessages(displayInput, historyInput string, newMessages []api.Message, extraSystemPrompt, skillName string) (tea.Model, tea.Cmd) {
m.refreshContextWindowTokens(m.opts.Model)
m.addPromptHistory(historyInput)
m.entries = append(m.entries, newChatEntry(chatEntry{role: "user", content: displayInput}))
if len(newMessages) > 1 {
+18 -11
View File
@@ -16,9 +16,11 @@ import (
)
type chatPromptDebug struct {
request api.ChatRequest
tokens int
scroll int
request api.ChatRequest
tokens int
scroll int
lines []string
linesWidth int
}
const maxPromptDebugToolResultRunes = 400
@@ -220,10 +222,13 @@ func (m chatModel) previewChatRequest(opts coreagent.RunOptions, messages []api.
return req
}
func (m chatModel) promptDebugLines(width int) []string {
func (m *chatModel) promptDebugLines(width int) []string {
if m.promptDebug == nil {
return nil
}
if m.promptDebug.lines != nil && m.promptDebug.linesWidth == width {
return m.promptDebug.lines
}
req := m.promptDebug.request
innerWidth := max(20, width-2)
lines := []string{
@@ -259,15 +264,17 @@ func (m chatModel) promptDebugLines(width int) []string {
lines = append(lines, "", chatHeaderStyle.Render("Tools"))
if len(req.Tools) == 0 {
lines = append(lines, chatMetaStyle.Render("none"))
return lines
}
for i, tool := range req.Tools {
if i > 0 {
lines = append(lines, "")
} else {
for i, tool := range req.Tools {
if i > 0 {
lines = append(lines, "")
}
lines = append(lines, promptDebugToolLines(i+1, tool, innerWidth)...)
}
lines = append(lines, promptDebugToolLines(i+1, tool, innerWidth)...)
}
return lines
m.promptDebug.lines = lines
m.promptDebug.linesWidth = width
return m.promptDebug.lines
}
func promptDebugFieldLine(label, value string, width int) string {
+5 -4
View File
@@ -75,15 +75,18 @@ func (m *chatModel) applyAgentEvent(event coreagent.Event) {
case coreagent.EventThinkingDelta:
m.awaitingModel = false
if event.Thinking != "" {
m.thinking = true
if event.Tokens > 0 {
m.thinkingTokens = max(m.thinkingTokens, event.Tokens)
} else {
m.thinkingTokens += approximateTokenCount(event.Thinking)
}
idx := m.ensureLiveAssistantMessage()
if !m.thinking {
m.thinkingPhaseStart = len(m.liveMessages[idx].Thinking)
}
m.thinking = true
m.liveMessages[idx].Thinking += event.Thinking
m.syncThinkingEntry()
m.syncThinkingEntry(m.liveMessages[idx].Thinking[m.thinkingPhaseStart:])
contextChanged = true
}
case coreagent.EventMessageDelta:
@@ -109,7 +112,6 @@ func (m *chatModel) applyAgentEvent(event coreagent.Event) {
contextChanged = true
case coreagent.EventToolStarted:
m.resetStreamingState()
m.refreshContextWindowTokens(m.opts.Model)
startedAt := time.Now()
idx := m.findActiveToolEntry(event.ToolCallID)
if idx < 0 {
@@ -127,7 +129,6 @@ func (m *chatModel) applyAgentEvent(event coreagent.Event) {
m.markEntryDirty(idx)
case coreagent.EventToolFinished:
m.resetStreamingState()
m.refreshContextWindowTokens(m.opts.Model)
if event.WorkingDir != "" {
m.workingDir = event.WorkingDir
}
+78
View File
@@ -22,6 +22,84 @@ func TestApplyAgentEventStreamsAssistantContent(t *testing.T) {
}
}
func TestApplyAgentEventStreamsThinkingThenCollapsesOnAssistantOrTool(t *testing.T) {
m := chatModel{running: true}
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventThinkingDelta, Thinking: "first "})
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventThinkingDelta, Thinking: "second", Tokens: 7})
if len(m.entries) != 1 || m.entries[0].role != "thinking" || !m.entries[0].expanded || m.entries[0].content != "first second" {
t.Fatalf("live thinking entry = %#v", m.entries)
}
if got := m.liveMessages[0].Thinking; got != "first second" {
t.Fatalf("live message thinking = %q, want full streamed value", got)
}
if view := stripANSI(m.renderTranscript(100)); !strings.Contains(view, "Thinking ↓ 7 tokens") || !strings.Contains(view, "first second") {
t.Fatalf("live thinking trace missing from transcript:\n%s", view)
}
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventMessageDelta, Content: "answer"})
if m.entries[0].status != "done" || m.entries[0].expanded {
t.Fatalf("assistant content should collapse thinking: %#v", m.entries[0])
}
collapsed := stripANSI(m.renderTranscript(100))
if !strings.Contains(collapsed, "Thought") || strings.Contains(collapsed, "7 tokens") || strings.Contains(collapsed, "first second") {
t.Fatalf("collapsed thinking should remain as a thought row without trace content:\n%s", collapsed)
}
if got := m.liveMessages[0].Thinking; got != "first second" {
t.Fatalf("collapsing display must not change request history: %q", got)
}
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventThinkingDelta, Thinking: "tool plan"})
if entry := m.entries[len(m.entries)-1]; entry.role != "thinking" || entry.content != "tool plan" {
t.Fatalf("second thinking phase should contain only its own deltas: %#v", entry)
}
if got := m.liveMessages[0].Thinking; got != "first secondtool plan" {
t.Fatalf("message history should retain both thinking phases exactly: %q", got)
}
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventToolStarted, ToolCallID: "call-1", ToolName: "bash"})
if entry := m.entries[len(m.entries)-2]; entry.role != "thinking" || entry.status != "done" || entry.expanded {
t.Fatalf("tool transition should collapse thinking: %#v", entry)
}
}
func TestApplyAgentEventDoesNotCreateThinkingEntryWithoutThinking(t *testing.T) {
m := chatModel{running: true}
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventThinkingDelta, Tokens: 12})
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventMessageDelta, Content: "answer"})
if len(m.entries) != 1 || m.entries[0].role != "assistant" {
t.Fatalf("empty thinking event should not create a trace: %#v", m.entries)
}
if len(m.liveMessages) != 1 || m.liveMessages[0].Thinking != "" {
t.Fatalf("empty thinking event should not alter message history: %#v", m.liveMessages)
}
}
func TestApplyAgentEventPreservesCollapsedThoughtsAcrossToolGrouping(t *testing.T) {
m := chatModel{running: true}
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventThinkingDelta, Thinking: "first plan", Tokens: 1})
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventToolStarted, ToolCallID: "call-1", ToolName: "bash"})
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventToolFinished, ToolCallID: "call-1", ToolName: "bash", Content: "one"})
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventThinkingDelta, Thinking: "second plan", Tokens: 1})
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventToolStarted, ToolCallID: "call-2", ToolName: "bash"})
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventToolFinished, ToolCallID: "call-2", ToolName: "bash", Content: "two"})
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventToolStarted, ToolCallID: "call-3", ToolName: "bash"})
if len(m.entries) != 5 {
t.Fatalf("entries = %#v, want two thought rows and three tool rows", m.entries)
}
for _, index := range []int{0, 2} {
entry := m.entries[index]
if entry.role != "thinking" || entry.status != "done" || entry.expanded {
t.Fatalf("collapsed thought %d = %#v", index, entry)
}
}
if transcript := stripANSI(m.renderTranscript(100)); strings.Count(transcript, "Thought") != 2 || strings.Contains(transcript, "1 token") {
t.Fatalf("transcript should retain both thought rows:\n%s", transcript)
}
}
func TestApplyAgentEventTracksToolLifecycle(t *testing.T) {
m := chatModel{running: true}
args := map[string]any{"command": "pwd"}
+253 -30
View File
@@ -51,12 +51,12 @@ const (
)
var chatSlashCommands = []chatSlashCommand{
{name: "/clear", description: "clear this chat"},
{name: "/model", description: "switch models"},
{name: "/new", description: "start a new chat"},
{name: "/think", description: "set thinking mode"},
{name: "/tools", description: "toggle tools on or off"},
{name: "/skills", description: "list available skills"},
{name: "/system", usage: "/system [on|off]", description: "show or set the built-in system prompt"},
{name: "/skills", usage: "/skills [import codex|claude|pi]", description: "list or import skills"},
{name: "/compact", description: "summarize older context"},
{name: "/help", description: "show commands", aliases: []string{"/?"}},
{name: "/bye", description: "exit", aliases: []string{"/exit"}},
@@ -64,12 +64,33 @@ var chatSlashCommands = []chatSlashCommand{
{name: "/save", usage: "/save <filename>", description: "save request JSON; saved as <filename>.json"},
}
var skillsImportCompletions = []chatCompletion{
{value: "/skills import codex", label: "/skills import codex", description: "import from ~/.codex/skills"},
{value: "/skills import claude", label: "/skills import claude", description: "import from ~/.claude/skills"},
{value: "/skills import pi", label: "/skills import pi", description: "import from ~/.pi/agent/skills"},
}
// BuiltinSlashCommandNames returns the names reserved by built-in slash
// commands, including aliases.
func BuiltinSlashCommandNames() []string {
names := make(map[string]struct{})
for _, command := range chatSlashCommands {
names[strings.TrimPrefix(command.name, "/")] = struct{}{}
for _, alias := range command.aliases {
names[strings.TrimPrefix(alias, "/")] = struct{}{}
}
}
reserved := make([]string, 0, len(names))
for name := range names {
reserved = append(reserved, name)
}
sort.Strings(reserved)
return reserved
}
func (m *chatModel) handleSubmit() (tea.Model, tea.Cmd) {
m.syncInputPlaceholders()
input := strings.TrimSpace(string(m.input))
if selected, ok := m.selectedSlashCommand(); ok {
input = selected
}
if input == "" {
return *m, nil
}
@@ -91,16 +112,32 @@ func (m *chatModel) handleSubmit() (tea.Model, tea.Cmd) {
return m.submitInput(input)
}
func (m chatModel) selectedSlashCommand() (string, bool) {
input := strings.TrimSpace(string(m.input))
func (m *chatModel) applySlashCompletion() bool {
rawInput := string(m.input)
input := strings.TrimSpace(rawInput)
if !strings.HasPrefix(input, "/") {
return "", false
return false
}
if _, _, known := slashCommandInvocation(input); known && !hasSystemCommandArgument(rawInput) {
return false
}
completions := m.slashCompletions()
if len(completions) == 0 || !completionIsSelectable(completions) {
return "", false
return false
}
return completions[clamp(m.complete, 0, len(completions)-1)].value, true
selected := completions[clamp(m.complete, 0, len(completions)-1)]
if strings.EqualFold(selected.value, input) {
return false
}
// Reset prompt-history state: Up/Down is shared between history recall and
// slash completion, and a recalled prompt may start with "/" and trigger
// completion. Keep the two in sync when we accept a completion.
m.resetPromptHistoryCursor()
m.input = []rune(selected.value)
m.inputCursor = len(m.input)
m.inputCursorSet = true
m.complete = 0
return true
}
func (m *chatModel) submitInput(input string) (tea.Model, tea.Cmd) {
@@ -117,8 +154,6 @@ func (m *chatModel) submitInput(input string) (tea.Model, tea.Cmd) {
case command == "/help":
m.entries = append(m.entries, newSlashEntry(m.helpSummary()))
return *m, nil
case command == "/clear" && args == "":
return m.resetChat("cleared")
case command == "/model":
return m.openModelPicker(args)
case command == "/think" && args == "":
@@ -127,6 +162,8 @@ func (m *chatModel) submitInput(input string) (tea.Model, tea.Cmd) {
return m.handleThinkCommand(args)
case command == "/tools":
return m.handleToolsCommand(args)
case command == "/system":
return m.handleSystemCommand(args)
case command == "/skills":
return m.handleSkillsCommand(args)
case command == "/prompt":
@@ -150,8 +187,10 @@ func (m *chatModel) submitInput(input string) (tea.Model, tea.Cmd) {
}
func (m *chatModel) handleSkillsCommand(args string) (tea.Model, tea.Cmd) {
if strings.TrimSpace(args) != "" {
m.entries = append(m.entries, newChatEntry(chatEntry{role: "error", content: "usage: /skills"}))
if fields := strings.Fields(args); len(fields) == 2 && fields[0] == "import" {
return m.handleSkillsImport(fields[1])
} else if len(fields) != 0 {
m.entries = append(m.entries, newChatEntry(chatEntry{role: "error", content: "usage: /skills [import codex|claude|pi]"}))
return *m, nil
}
skills := m.opts.Skills.List()
@@ -172,6 +211,61 @@ func (m *chatModel) handleSkillsCommand(args string) (tea.Model, tea.Cmd) {
return *m, nil
}
func (m *chatModel) handleSkillsImport(source string) (tea.Model, tea.Cmd) {
importSkills := m.opts.ImportSkills
if importSkills == nil {
importSkills = coreagent.ImportSkills
}
result, err := importSkills(source)
if err != nil {
m.status = "error"
m.entries = append(m.entries, newChatEntry(chatEntry{role: "error", content: fmt.Sprintf("Could not import %s skills: %v", source, err)}))
return *m, nil
}
if len(result.Imported) != 0 || len(result.Existing) != 0 {
reload := m.opts.ReloadSkills
if reload == nil {
reload = func() (*coreagent.SkillCatalog, error) {
return coreagent.LoadDefaultSkills(m.currentWorkingDir())
}
}
catalog, err := reload()
if err != nil {
m.status = "error"
m.entries = append(m.entries, newChatEntry(chatEntry{role: "error", content: fmt.Sprintf("%s\n\nCould not reload skills: %v", skillsImportSummary(result), err)}))
return *m, nil
}
m.opts.Skills = catalog
if m.opts.ToolRegistryForModel != nil && m.opts.Model != "" {
m.opts.Tools = m.opts.ToolRegistryForModel(m.ctx, m.opts.Model)
}
if m.opts.SystemPromptForModel != nil {
m.opts.SystemPrompt = m.opts.SystemPromptForModel(m.ctx, m.opts.Model, m.opts.Tools, m.opts.ToolsDisabled)
}
m.status = "skills reloaded"
}
m.entries = append(m.entries, newSlashEntry(skillsImportSummary(result)))
return *m, nil
}
func skillsImportSummary(result coreagent.SkillImportResult) string {
if len(result.Imported) == 0 && len(result.Existing) == 0 && len(result.Failures) == 0 {
return fmt.Sprintf("No %s skills found at %s.", result.Source, result.SourceDir)
}
var lines []string
if len(result.Imported) != 0 {
lines = append(lines, fmt.Sprintf("Imported %d skill%s from %s.", len(result.Imported), pluralSuffix(len(result.Imported)), result.SourceDir))
}
if len(result.Existing) != 0 {
lines = append(lines, "Already present (left unchanged): "+strings.Join(result.Existing, ", ")+".")
}
for _, failure := range result.Failures {
lines = append(lines, fmt.Sprintf("Skipped %s: %v.", failure.Name, failure.Err))
}
return strings.Join(lines, "\n")
}
func skillsDirForDisplay(catalog *coreagent.SkillCatalog) string {
if catalog != nil && catalog.Dir() != "" {
return catalog.Dir()
@@ -228,6 +322,40 @@ func (m *chatModel) handleToolsCommand(args string) (tea.Model, tea.Cmd) {
return *m, nil
}
func (m *chatModel) handleSystemCommand(args string) (tea.Model, tea.Cmd) {
switch strings.ToLower(strings.TrimSpace(args)) {
case "":
m.entries = append(m.entries, newSlashEntry(m.systemCommandOutput()))
case "on":
m.systemPromptDisabled = false
m.status = "system prompt on"
m.entries = append(m.entries, newSlashEntry(m.systemCommandOutput()))
case "off":
m.systemPromptDisabled = true
m.status = "system prompt off"
m.entries = append(m.entries, newSlashEntry(m.systemCommandOutput()))
default:
m.status = "error"
m.entries = append(m.entries, newChatEntry(chatEntry{role: "error", content: "usage: /system [on|off]"}))
}
return *m, nil
}
func (m chatModel) systemPromptState() string {
if m.systemPromptDisabled {
return "off"
}
return "on"
}
func (m chatModel) systemCommandOutput() string {
prompt := strings.TrimSpace(m.opts.SystemPrompt)
if prompt == "" {
prompt = "(empty)"
}
return "Built-in system prompt is " + m.systemPromptState() + ".\n\n" + prompt + "\n\nWarning: Changing the system prompt during a session breaks the prompt cache."
}
func (m chatModel) slashInputIsMultimodalFile(input string) bool {
if !m.opts.MultiModal {
return false
@@ -1108,13 +1236,19 @@ func (m chatModel) completions() []chatCompletion {
func (m chatModel) slashCompletions() []chatCompletion {
rawInput := string(m.input)
input := strings.TrimSpace(rawInput)
input := strings.TrimLeftFunc(rawInput, unicode.IsSpace)
if !strings.HasPrefix(input, "/") {
return nil
}
if argument, ok := systemCommandArgument(rawInput); ok {
return systemCommandCompletions(argument)
}
if m.skillSlashPromptStarted(rawInput) {
return nil
}
if completions := matchingSkillsImportCompletions(input); completions != nil {
return completions
}
commands := matchingSlashCommands(input)
completions := make([]chatCompletion, 0, len(commands))
@@ -1125,6 +1259,13 @@ func (m chatModel) slashCompletions() []chatCompletion {
description: command.description,
})
}
if strings.EqualFold(input, "/skills") {
completions = append(completions, chatCompletion{
value: "/skills import",
label: "/skills import",
description: "import skills from Codex, Claude, or Pi",
})
}
// Each catalog skill is also invocable as "/<skill-name>"; surface them as
// completions so they are discoverable by typing.
if m.opts.Skills != nil {
@@ -1154,6 +1295,74 @@ func (m chatModel) slashCompletions() []chatCompletion {
return completions
}
func matchingSkillsImportCompletions(input string) []chatCompletion {
const importCommand = "/skills import"
lower := strings.ToLower(input)
if lower == "/skills" {
return nil // Preserve Enter on /skills as the listing command.
}
if !strings.HasPrefix(lower, "/skills ") {
return nil
}
if strings.HasPrefix(importCommand, lower) {
return []chatCompletion{{
value: importCommand,
label: importCommand,
description: "import skills from Codex, Claude, or Pi",
}}
}
if !strings.HasPrefix(lower, importCommand) {
return nil
}
prefix := strings.TrimSpace(strings.TrimPrefix(lower, importCommand))
completions := make([]chatCompletion, 0, len(skillsImportCompletions))
for _, completion := range skillsImportCompletions {
if strings.HasPrefix(strings.TrimPrefix(completion.value, importCommand+" "), prefix) {
completions = append(completions, completion)
}
}
if len(completions) == 0 {
return []chatCompletion{{label: "No matching skill sources"}}
}
return completions
}
func hasSystemCommandArgument(input string) bool {
_, ok := systemCommandArgument(input)
return ok
}
func systemCommandArgument(input string) (string, bool) {
input = strings.TrimLeftFunc(input, unicode.IsSpace)
end := strings.IndexFunc(input, unicode.IsSpace)
if end < 0 {
return "", false
}
command, _, known := slashCommandInvocation(input[:end])
if !known || command != "/system" {
return "", false
}
return strings.TrimSpace(input[end:]), true
}
func systemCommandCompletions(argument string) []chatCompletion {
argument = strings.ToLower(argument)
options := []chatCompletion{
{value: "/system on", label: "on", description: "enable the built-in system prompt"},
{value: "/system off", label: "off", description: "disable the built-in system prompt"},
}
completions := make([]chatCompletion, 0, len(options))
for _, option := range options {
if strings.HasPrefix(option.label, argument) {
completions = append(completions, option)
}
}
if len(completions) == 0 {
return []chatCompletion{{label: "No matching options"}}
}
return completions
}
func (m chatModel) skillSlashPromptStarted(input string) bool {
input = strings.TrimLeftFunc(input, unicode.IsSpace)
end := strings.IndexFunc(input, unicode.IsSpace)
@@ -1210,8 +1419,7 @@ func slashCommandInvocation(input string) (string, string, bool) {
}
func (m chatModel) mentionCompletions() []chatCompletion {
input := string(m.input)
_, query, ok := activeMentionToken(input)
_, query, ok := activeMentionToken(m.input, m.normalizedInputCursor())
if !ok {
return nil
}
@@ -1275,13 +1483,13 @@ func (m chatModel) mentionCompletions() []chatCompletion {
return completions
}
func activeMentionToken(input string) (int, string, bool) {
runes := []rune(input)
start := len(runes)
for start > 0 && !unicode.IsSpace(runes[start-1]) {
func activeMentionToken(input []rune, cursor int) (int, string, bool) {
cursor = clamp(cursor, 0, len(input))
start := cursor
for start > 0 && !unicode.IsSpace(input[start-1]) {
start--
}
token := string(runes[start:])
token := string(input[start:cursor])
if !strings.HasPrefix(token, "@") {
return 0, "", false
}
@@ -1339,6 +1547,7 @@ func (m *chatModel) applyCompletion() bool {
}
m.resetPromptHistoryCursor()
selected := completions[clamp(m.complete, 0, len(completions)-1)]
cursor := m.normalizedInputCursor()
input := string(m.input)
if strings.HasPrefix(strings.TrimSpace(input), "/") {
m.input = []rune(selected.value)
@@ -1348,22 +1557,35 @@ func (m *chatModel) applyCompletion() bool {
return true
}
start, _, ok := activeMentionToken(input)
start, _, ok := activeMentionToken(m.input, cursor)
if !ok {
return false
}
suffix := ""
if !selected.directory {
suffix = " "
completed := []rune("@" + selected.value)
if !selected.directory && (cursor == len(m.input) || !unicode.IsSpace(m.input[cursor])) {
completed = append(completed, ' ')
}
next := make([]rune, 0, len(m.input)-cursor+start+len(completed))
next = append(next, m.input[:start]...)
next = append(next, completed...)
next = append(next, m.input[cursor:]...)
m.input = next
m.inputCursor = start + len(completed)
if !selected.directory && m.inputCursor < len(m.input) && unicode.IsSpace(m.input[m.inputCursor]) {
m.inputCursor++
}
next := string([]rune(input)[:start]) + "@" + selected.value + suffix
m.input = []rune(next)
m.inputCursor = len(m.input)
m.inputCursorSet = true
m.complete = 0
return true
}
func (m *chatModel) applyMentionCompletion() bool {
if strings.HasPrefix(strings.TrimSpace(string(m.input)), "/") {
return false
}
return m.applyCompletion()
}
func completionIsSelectable(completions []chatCompletion) bool {
return len(completions) > 0 && completions[0].value != ""
}
@@ -1389,6 +1611,7 @@ func (m chatModel) helpSummary() string {
"",
"- `shift+enter`: insert a newline",
"- `shift+tab`: toggle permission mode",
"- `ctrl+o`: toggle transcript details",
"- `↑/↓`: previous or next prompt",
"- `ctrl+a/e`: move to line start or end",
)
@@ -1397,7 +1620,7 @@ func (m chatModel) helpSummary() string {
func (m chatModel) systemPrompt(extra string) string {
var parts []string
if strings.TrimSpace(m.opts.SystemPrompt) != "" {
if !m.systemPromptDisabled && strings.TrimSpace(m.opts.SystemPrompt) != "" {
parts = append(parts, strings.TrimSpace(m.opts.SystemPrompt))
}
if strings.TrimSpace(extra) != "" {
+340 -5
View File
@@ -30,6 +30,7 @@ func TestChatHelpCommandShowsV1Commands(t *testing.T) {
"**Commands**",
"- `/model`: switch models",
"- `/think`: set thinking mode",
"- `/system [on|off]`: show or set the built-in system prompt",
"- `/compact`: summarize older context",
"- `/help`: show commands",
"- `/bye`: exit",
@@ -38,6 +39,7 @@ func TestChatHelpCommandShowsV1Commands(t *testing.T) {
"**Shortcuts**",
"- `shift+enter`: insert a newline",
"- `shift+tab`: toggle permission mode",
"- `ctrl+o`: toggle transcript details",
} {
if !strings.Contains(fm.entries[0].content, want) {
t.Fatalf("help output missing %q:\n%s", want, fm.entries[0].content)
@@ -370,6 +372,36 @@ func TestChatPromptDebugMouseWheelScrolls(t *testing.T) {
}
}
func TestChatPromptDebugCachesLinesByWidth(t *testing.T) {
m := chatModel{
promptDebug: &chatPromptDebug{
request: api.ChatRequest{
Model: "llama3.2",
Messages: []api.Message{{
Role: "user",
Content: strings.Repeat("a long prompt line ", 20),
}},
},
},
}
first := m.promptDebugLines(80)
if len(first) == 0 || m.promptDebug.linesWidth != 80 {
t.Fatalf("prompt cache = %#v, want lines cached at width 80", m.promptDebug)
}
if &first[0] != &m.promptDebugLines(80)[0] {
t.Fatal("prompt debug should reuse cached lines at the same width")
}
resized := m.promptDebugLines(120)
if m.promptDebug.linesWidth != 120 {
t.Fatalf("prompt cache width = %d, want 120", m.promptDebug.linesWidth)
}
if &first[0] == &resized[0] {
t.Fatal("prompt debug should rebuild lines after a width change")
}
}
func TestTruncateInputLineUsesDisplayWidth(t *testing.T) {
line := truncateInputLine(strings.Repeat("界", 10), 10)
if got := lipgloss.Width(line); got > 10 {
@@ -570,6 +602,76 @@ func TestSkillCommandsListAndPersistSyntheticToolCall(t *testing.T) {
}
}
func TestSkillsImportReloadsCatalogRegistryAndSystemPrompt(t *testing.T) {
before := writeTestSkillCatalog(t)
dir := t.TempDir()
if err := os.Mkdir(filepath.Join(dir, "from-codex"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "from-codex", "SKILL.md"), []byte("---\nname: from-codex\ndescription: Imported skill.\n---\nImported instructions."), 0o644); err != nil {
t.Fatal(err)
}
after, err := coreagent.DiscoverSkills(dir)
if err != nil {
t.Fatal(err)
}
registry := &coreagent.Registry{}
var reloaded, rebuilt, prompted bool
m := chatModel{
ctx: context.Background(),
opts: Options{
Model: "test",
Skills: before,
ImportSkills: func(source string) (coreagent.SkillImportResult, error) {
if source != "codex" {
t.Fatalf("source = %q", source)
}
return coreagent.SkillImportResult{Source: source, SourceDir: "/source", Imported: []string{"from-codex"}}, nil
},
ReloadSkills: func() (*coreagent.SkillCatalog, error) {
reloaded = true
return after, nil
},
ToolRegistryForModel: func(context.Context, string) *coreagent.Registry {
rebuilt = true
return registry
},
SystemPromptForModel: func(_ context.Context, _ string, got *coreagent.Registry, _ bool) string {
prompted = got == registry
return after.SystemContext()
},
},
input: []rune("/skills import codex"),
}
updated, cmd := m.handleSubmit()
if cmd != nil {
t.Fatal("skills import should not start a model run")
}
m = updated.(chatModel)
if !reloaded || !rebuilt || !prompted {
t.Fatalf("reload=%v rebuilt=%v prompted=%v", reloaded, rebuilt, prompted)
}
if m.opts.Skills != after || m.opts.Tools != registry || !strings.Contains(m.opts.SystemPrompt, "from-codex") {
t.Fatalf("reloaded options = %#v", m.opts)
}
if m.status != "skills reloaded" || len(m.entries) != 1 || !strings.Contains(m.entries[0].content, "Imported 1 skill") {
t.Fatalf("import result = status %q entries %#v", m.status, m.entries)
}
}
func TestSkillsImportUsage(t *testing.T) {
m := chatModel{input: []rune("/skills import")}
updated, cmd := m.handleSubmit()
if cmd != nil {
t.Fatal("invalid skills import should not start a model run")
}
m = updated.(chatModel)
if len(m.entries) != 1 || m.entries[0].role != "error" || !strings.Contains(m.entries[0].content, "usage: /skills [import codex|claude|pi]") {
t.Fatalf("entries = %#v", m.entries)
}
}
func TestSkillSlashCommandPromptBecomesUserMessage(t *testing.T) {
catalog := writeTestSkillCatalog(t)
m := chatModel{ctx: context.Background(), opts: Options{Model: "test", Skills: catalog, Client: chatTestClient{}}, input: []rune("/release-notes draft the v1.2 notes")}
@@ -665,6 +767,31 @@ func TestSkillSlashCommandAppearsInCompletions(t *testing.T) {
}
}
func TestSkillsImportSlashCompletions(t *testing.T) {
for _, test := range []struct {
input string
want []string
}{
{input: "/skills", want: []string{"/skills", "/skills import"}},
{input: "/skills impo", want: []string{"/skills import"}},
{input: "/skills import ", want: []string{"/skills import codex", "/skills import claude", "/skills import pi"}},
{input: "/skills import c", want: []string{"/skills import codex", "/skills import claude"}},
{input: "/skills import pi", want: []string{"/skills import pi"}},
} {
t.Run(test.input, func(t *testing.T) {
m := chatModel{input: []rune(test.input)}
completions := m.slashCompletions()
got := make([]string, 0, len(completions))
for _, completion := range completions {
got = append(got, completion.value)
}
if strings.Join(got, "\n") != strings.Join(test.want, "\n") {
t.Fatalf("completions = %#v, want %#v", got, test.want)
}
})
}
}
func TestSkillSlashPromptHidesCommandCompletions(t *testing.T) {
catalog := writeTestSkillCatalog(t)
for _, input := range []string{"/release-notes ", "/release-notes draft the release notes"} {
@@ -708,7 +835,7 @@ func TestSkillSlashNameResolvesAndRejectsArgsAndUnknown(t *testing.T) {
}
func TestChatDeletedSlashCommandsAreUnknown(t *testing.T) {
for _, command := range []string{"/copy", "/copy-all", "/launch", "/system", "/history", "/load", "/raw", "/resume", "/set", "/show", "/verbose"} {
for _, command := range []string{"/clear", "/copy", "/copy-all", "/launch", "/history", "/load", "/raw", "/resume", "/set", "/show", "/verbose"} {
t.Run(command, func(t *testing.T) {
m := chatModel{input: []rune(command)}
@@ -732,12 +859,12 @@ func TestChatViewRendersSlashCommandSuggestions(t *testing.T) {
}
view := stripANSI(m.View())
for _, want := range []string{"/clear", "/model", "/new", "/think", "/tools"} {
for _, want := range []string{"/model", "/new", "/think", "/tools", "/system"} {
if !strings.Contains(view, want) {
t.Fatalf("view missing %s suggestion: %q", want, view)
}
}
for _, removed := range []string{"/copy", "/copy-all", "/history", "/load", "/raw", "/resume", "/set", "/show", "/verbose"} {
for _, removed := range []string{"/clear", "/copy", "/copy-all", "/history", "/load", "/raw", "/resume", "/set", "/show", "/verbose"} {
if strings.Contains(view, removed) {
t.Fatalf("bare slash should hide removed command %s: %q", removed, view)
}
@@ -843,6 +970,127 @@ func TestChatToolsCommandUsage(t *testing.T) {
}
}
func TestChatSystemCommandControlsBuiltInSystemPrompt(t *testing.T) {
client := &chatCaptureClient{}
m := chatModel{
ctx: context.Background(),
input: []rune("/system"),
opts: Options{
Model: "test",
Client: client,
SystemPrompt: "canonical agent prompt",
},
}
updated, cmd := m.handleSubmit()
if cmd != nil {
t.Fatal("/system should not start a run")
}
m = updated.(chatModel)
if len(m.entries) != 1 || m.entries[0].role != "slash" || m.entries[0].content != "Built-in system prompt is on.\n\ncanonical agent prompt\n\nWarning: Changing the system prompt during a session breaks the prompt cache." {
t.Fatalf("/system entry = %#v", m.entries)
}
m.input = []rune("/system off")
updated, _ = m.handleSubmit()
m = updated.(chatModel)
if !m.systemPromptDisabled || m.status != "system prompt off" {
t.Fatalf("/system off state = disabled:%v status:%q", m.systemPromptDisabled, m.status)
}
m.input = []rune("/system")
updated, _ = m.handleSubmit()
m = updated.(chatModel)
if got := m.entries[len(m.entries)-1].content; got != "Built-in system prompt is off.\n\ncanonical agent prompt\n\nWarning: Changing the system prompt during a session breaks the prompt cache." {
t.Fatalf("/system off entry = %q", got)
}
updated, cmd = m.startRun("hello")
if cmd == nil {
t.Fatal("run after /system off should start")
}
m = updated.(chatModel)
if done := waitForRunDone(t, m.events); done.err != nil {
t.Fatalf("run after /system off: %v", done.err)
}
if len(client.requests) != 1 || len(client.requests[0].Messages) != 1 || client.requests[0].Messages[0].Role != "user" {
t.Fatalf("request after /system off = %#v", client.requests)
}
m.input = []rune("/system ON")
updated, _ = m.handleSubmit()
m = updated.(chatModel)
if m.systemPromptDisabled || m.status != "system prompt on" {
t.Fatalf("/system on state = disabled:%v status:%q", m.systemPromptDisabled, m.status)
}
updated, cmd = m.startRun("hello again")
if cmd == nil {
t.Fatal("run after /system on should start")
}
m = updated.(chatModel)
if done := waitForRunDone(t, m.events); done.err != nil {
t.Fatalf("run after /system on: %v", done.err)
}
if len(client.requests) != 2 {
t.Fatalf("client requests = %d, want 2", len(client.requests))
}
request := client.requests[1]
if len(request.Messages) != 2 || request.Messages[0].Role != "system" || request.Messages[0].Content != "canonical agent prompt" {
t.Fatalf("request after /system on = %#v", request.Messages)
}
m.input = []rune("/system sometimes")
updated, _ = m.handleSubmit()
m = updated.(chatModel)
if m.status != "error" || len(m.entries) == 0 || m.entries[len(m.entries)-1].content != "usage: /system [on|off]" {
t.Fatalf("invalid /system result = status:%q entries:%#v", m.status, m.entries)
}
}
func TestChatSystemCommandArgumentCompletions(t *testing.T) {
for _, tt := range []struct {
input string
want []string
}{
{input: "/system ", want: []string{"/system on", "/system off"}},
{input: "/system o", want: []string{"/system on", "/system off"}},
{input: "/system on", want: []string{"/system on"}},
} {
t.Run(tt.input, func(t *testing.T) {
m := chatModel{input: []rune(tt.input)}
completions := m.slashCompletions()
if len(completions) != len(tt.want) {
t.Fatalf("completions = %#v, want %d", completions, len(tt.want))
}
for i, want := range tt.want {
if completions[i].value != want {
t.Fatalf("completion %d = %q, want %q", i, completions[i].value, want)
}
}
})
}
m := chatModel{input: []rune("/system ")}
lines := stripANSI(strings.Join(m.slashCommandLines(80), "\n"))
for _, want := range []string{"on", "enable the built-in system prompt", "off", "disable the built-in system prompt"} {
if !strings.Contains(lines, want) {
t.Fatalf("/system option suggestions missing %q: %q", want, lines)
}
}
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
if cmd != nil {
t.Fatal("selecting /system on should not submit the command")
}
m = updated.(chatModel)
if got := string(m.input); got != "/system on" {
t.Fatalf("input = %q, want /system on", got)
}
m.input = []rune("/system maybe")
completions := m.slashCompletions()
if len(completions) != 1 || completions[0].label != "No matching options" {
t.Fatalf("invalid argument completions = %#v", completions)
}
}
func TestChatSlashCommandSuggestionsIncludePromptAndSave(t *testing.T) {
for _, tt := range []struct {
input string
@@ -872,19 +1120,65 @@ func TestChatSlashCommandSuggestionsIncludeThink(t *testing.T) {
}
}
func TestChatEnterAcceptsSelectedSlashCommand(t *testing.T) {
func TestChatEnterFillsSelectedSlashCommandBeforeSubmitting(t *testing.T) {
m := chatModel{input: []rune("/th")}
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(chatModel)
if cmd != nil {
t.Fatal("filling a slash command should not return a command")
}
if got := string(m.input); got != "/think" {
t.Fatalf("input = %q, want completed command", got)
}
if m.thinkPicker != nil {
t.Fatal("filling a slash command should not open its picker")
}
updated, cmd = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(chatModel)
if cmd != nil {
t.Fatal("think command should not return a command")
}
if m.thinkPicker == nil {
t.Fatal("selected /think command should open picker")
t.Fatal("second enter should submit the completed /think command")
}
}
func TestChatEnterSubmitsExactSlashCommandAliases(t *testing.T) {
t.Run("help", func(t *testing.T) {
m := chatModel{input: []rune("/?")}
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
if cmd != nil {
t.Fatal("help alias should not return a command")
}
m = updated.(chatModel)
if len(m.entries) != 1 || m.entries[0].role != "slash" {
t.Fatalf("entries = %#v, want help output", m.entries)
}
if got := string(m.input); got != "" {
t.Fatalf("input = %q, want cleared after submitting alias", got)
}
})
t.Run("exit", func(t *testing.T) {
m := chatModel{input: []rune("/exit")}
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
if cmd == nil {
t.Fatal("exit alias should return the quit command")
}
m = updated.(chatModel)
if !m.quitting {
t.Fatal("exit alias should quit without filling /bye first")
}
if got := string(m.input); got != "" {
t.Fatalf("input = %q, want cleared after submitting alias", got)
}
})
}
func TestChatSlashCommandsRunWhileModelResponds(t *testing.T) {
m := chatModel{running: true, input: []rune("/help")}
@@ -1014,3 +1308,44 @@ func TestChatFileMentionSuggestionsFilterAndComplete(t *testing.T) {
t.Fatalf("completed input = %q", got)
}
}
func TestChatEnterCompletesHighlightedFileMentionWithoutSubmitting(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "alpha.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "target.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
m := chatModel{
workingDir: dir,
input: []rune("review @ after this"),
inputCursor: len([]rune("review @")),
inputCursorSet: true,
}
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown})
m = updated.(chatModel)
if got, want := m.complete, 1; got != want {
t.Fatalf("selected completion = %d, want %d", got, want)
}
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
if cmd != nil {
t.Fatal("selecting a file mention should not submit the prompt")
}
m = updated.(chatModel)
if got, want := string(m.input), "review @target.md after this"; got != want {
t.Fatalf("input = %q, want %q", got, want)
}
if got, want := m.inputCursor, len([]rune("review @target.md ")); got != want || !m.inputCursorSet {
t.Fatalf("cursor = %d (set=%v), want %d after the inserted mention", got, m.inputCursorSet, want)
}
if len(m.entries) != 0 || len(m.messages) != 0 {
t.Fatalf("selecting a file mention submitted the prompt: entries=%#v messages=%#v", m.entries, m.messages)
}
if completions := m.mentionCompletions(); completions != nil {
t.Fatalf("mention selector remained visible after selection: %#v", completions)
}
}
+162 -31
View File
@@ -2,8 +2,11 @@ package chat
import (
"strings"
"unicode"
"unicode/utf8"
"github.com/charmbracelet/lipgloss"
"github.com/mattn/go-runewidth"
)
func renderMarkdownForView(markdown string, width int) string {
@@ -34,7 +37,7 @@ func renderMarkdownForView(markdown string, width int) string {
}
if heading, ok := markdownHeading(trimmed); ok {
rendered = append(rendered, chatHeaderStyle.Render(heading))
rendered = append(rendered, chatHeaderStyle.Render(renderMarkdownRunes(parseMarkdownInline(heading))))
continue
}
@@ -42,9 +45,7 @@ func renderMarkdownForView(markdown string, width int) string {
rendered = append(rendered, "")
continue
}
for _, wrapped := range wrapChatText(line, width) {
rendered = append(rendered, renderMarkdownInline(wrapped))
}
rendered = append(rendered, wrapMarkdownInline(line, width)...)
}
return strings.Join(rendered, "\n")
}
@@ -71,22 +72,148 @@ func markdownHeading(line string) (string, bool) {
return strings.TrimSpace(line[level:]), true
}
func renderMarkdownInline(line string) string {
type markdownInlineStyle uint8
const (
markdownPlain markdownInlineStyle = iota
markdownStrong
markdownCode
)
type markdownInlineRune struct {
r rune
style markdownInlineStyle
}
// wrapMarkdownInline parses a complete source line before wrapping it. That
// keeps emphasis intact when its opening and closing delimiters land on
// different visual lines.
func wrapMarkdownInline(line string, width int) []string {
return wrapInlineRunes(parseMarkdownInline(line), width)
}
func wrapInlineRunes(runes []markdownInlineRune, width int) []string {
if len(runes) == 0 {
return []string{""}
}
var rendered []string
for len(runes) > 0 {
hardCut, spaceCut, currentWidth := 0, 0, 0
for i, item := range runes {
nextWidth := currentWidth + runewidth.RuneWidth(item.r)
if nextWidth > width {
break
}
currentWidth = nextWidth
hardCut = i + 1
if unicode.IsSpace(item.r) && currentWidth > width/2 {
spaceCut = i
}
}
cut := hardCut
if spaceCut > 0 {
cut = spaceCut
}
if cut == 0 {
cut = 1
}
lineRunes := trimMarkdownSpace(runes[:cut])
rendered = append(rendered, renderMarkdownRunes(lineRunes))
runes = trimMarkdownSpace(runes[cut:])
}
return rendered
}
func parseMarkdownInline(line string) []markdownInlineRune {
var out []markdownInlineRune
for len(line) > 0 {
if strings.HasPrefix(line, "`") {
if end := strings.Index(line[1:], "`"); end >= 0 {
out = appendMarkdownRunes(out, line[1:end+1], markdownCode)
line = line[end+2:]
continue
}
}
if (strings.HasPrefix(line, "**") || strings.HasPrefix(line, "__")) && canOpenMarkdownStrong(out) {
delimiter := line[:2]
if end := strings.Index(line[2:], delimiter); end >= 0 {
out = appendMarkdownRunes(out, line[2:end+2], markdownStrong)
line = line[end+4:]
continue
}
}
r, size := utf8.DecodeRuneInString(line)
out = append(out, markdownInlineRune{r: r, style: markdownPlain})
line = line[size:]
}
return out
}
// canOpenMarkdownStrong keeps delimiter-like text in bare URLs and identifiers
// literal, only treating ** / __ as strong emphasis at the common
// whitespace- or punctuation-delimited form.
func canOpenMarkdownStrong(out []markdownInlineRune) bool {
if len(out) == 0 {
return true
}
previous := out[len(out)-1].r
return (unicode.IsSpace(previous) || unicode.IsPunct(previous)) && !markdownStrongInURL(out)
}
func markdownStrongInURL(out []markdownInlineRune) bool {
start := len(out)
for start > 0 && !unicode.IsSpace(out[start-1].r) {
start--
}
var token strings.Builder
for _, item := range out[start:] {
token.WriteRune(item.r)
}
return strings.Contains(token.String(), "://")
}
func appendMarkdownRunes(out []markdownInlineRune, text string, style markdownInlineStyle) []markdownInlineRune {
for _, r := range text {
out = append(out, markdownInlineRune{r: r, style: style})
}
return out
}
func trimMarkdownSpace(runes []markdownInlineRune) []markdownInlineRune {
start, end := 0, len(runes)
for start < end && unicode.IsSpace(runes[start].r) {
start++
}
for end > start && unicode.IsSpace(runes[end-1].r) {
end--
}
return runes[start:end]
}
func renderMarkdownRunes(runes []markdownInlineRune) string {
var b strings.Builder
for {
before, rest, ok := strings.Cut(line, "`")
b.WriteString(before)
if !ok {
break
for start := 0; start < len(runes); {
end := start + 1
for end < len(runes) && runes[end].style == runes[start].style {
end++
}
code, after, ok := strings.Cut(rest, "`")
if !ok {
b.WriteString("`")
b.WriteString(rest)
break
var text strings.Builder
for _, item := range runes[start:end] {
text.WriteRune(item.r)
}
b.WriteString(chatInlineCodeStyle.Render(code))
line = after
switch runes[start].style {
case markdownStrong:
b.WriteString(chatStrongStyle.Render(text.String()))
case markdownCode:
b.WriteString(chatInlineCodeStyle.Render(text.String()))
default:
b.WriteString(text.String())
}
start = end
}
return b.String()
}
@@ -130,7 +257,7 @@ func renderMarkdownTable(lines []string, width int) ([]string, int) {
if i < len(row) {
cell = row[i]
}
naturalWidths[i] = max(naturalWidths[i], lipglossWidth(cell))
naturalWidths[i] = max(naturalWidths[i], markdownInlineWidth(cell))
}
}
widths := markdownTableColumnWidths(naturalWidths, width)
@@ -232,19 +359,21 @@ func sumInts(values []int) int {
}
func wrapMarkdownTableCell(cell string, width int) []string {
width = max(1, width)
var out []string
line := strings.TrimSpace(cell)
for lipglossWidth(line) > width {
cut := chatDisplayWidthCut(line, width)
out = append(out, strings.TrimSpace(line[:cut]))
line = strings.TrimSpace(line[cut:])
}
out = append(out, line)
if len(out) == 0 {
lines := wrapInlineRunes(parseMarkdownInline(cell), max(1, width))
if len(lines) == 0 {
return []string{""}
}
return out
return lines
}
// markdownInlineWidth reports the visible width of a cell once Markdown
// delimiters are parsed away, so columns size to rendered content.
func markdownInlineWidth(cell string) int {
width := 0
for _, item := range parseMarkdownInline(cell) {
width += runewidth.RuneWidth(item.r)
}
return width
}
func looksLikeMarkdownTableRow(line string) bool {
@@ -258,8 +387,10 @@ func isMarkdownTableSeparator(line string) bool {
return false
}
for _, cell := range cells {
cell = strings.Trim(cell, " :-")
if cell != "" {
cell = strings.TrimSpace(cell)
cell = strings.TrimPrefix(cell, ":")
cell = strings.TrimSuffix(cell, ":")
if cell == "" || strings.Trim(cell, "-") != "" {
return false
}
}
+95 -37
View File
@@ -4,15 +4,16 @@ import (
"context"
"encoding/json"
"fmt"
"regexp"
"slices"
"sort"
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/mattn/go-runewidth"
coreagent "github.com/ollama/ollama/agent"
@@ -33,6 +34,7 @@ type chatEntry struct {
finishedAt time.Time
tools []chatEntry
metrics *api.Metrics
tokenCount int
version int
renderKey chatEntryRenderKey
@@ -43,6 +45,7 @@ const (
chatMessageIndent = " "
chatUserMessagePrefix = ""
maxCtrlOToolOutputRunes = 400
maxLiveThinkingRunes = 4096
defaultViewWidth = 80
defaultViewHeight = 24
@@ -384,10 +387,17 @@ func (m *chatModel) scrollBy(lines int) {
m.scroll = clamp(m.scroll+lines, 0, m.maxScroll())
}
var chatANSISequencePattern = regexp.MustCompile(`\x1b\[[0-9;:]*[A-Za-z]`)
func stripChatANSI(s string) string {
return chatANSISequencePattern.ReplaceAllString(s, "")
s = ansi.Strip(s)
return strings.Map(func(r rune) rune {
if r == '\n' || r == '\t' {
return r
}
if unicode.IsControl(r) {
return -1
}
return r
}, s)
}
func (m chatModel) normalizedSelectionRange() (chatSelectionPoint, chatSelectionPoint, bool) {
@@ -669,20 +679,71 @@ func compactionSummaryStatusLine(entry chatEntry) string {
}
func renderThinkingLines(entry chatEntry, width int) []string {
if !entry.expanded || strings.TrimSpace(entry.content) == "" {
return nil
}
lines := wrapChatText(thinkingStatusLine(entry), width)
if !entry.expanded || entry.content == "" {
return lines
}
lines = append(lines, "")
lines = append(lines, indentLines(splitRenderedBody(renderMarkdownForView(entry.content, width-2)), " ")...)
if entry.status == "running" {
body := styleLines(renderLiveThinkingLines(entry.content, width), chatToolOutputStyle)
lines = append(lines, body...)
return lines
}
body := styleLines(splitRenderedBody(renderMarkdownForView(stripChatANSI(entry.content), width)), chatToolOutputStyle)
lines = append(lines, body...)
return lines
}
func thinkingStatusLine(entry chatEntry) string {
if strings.TrimSpace(entry.label) != "" {
return entry.label
// renderLiveThinkingLines keeps each streaming redraw bounded. Completed
// traces use the normal Markdown renderer when explicitly reopened, but a
// running trace is rendered as plain text so an ever-growing document is not
// reparsed for every delta.
func renderLiveThinkingLines(content string, width int) []string {
content, omitted := liveThinkingTail(content, maxLiveThinkingRunes)
content = stripChatANSI(content)
lines := wrapChatText(content, width)
if omitted {
lines = append([]string{"… earlier thinking omitted while streaming"}, lines...)
}
return "Thinking"
return lines
}
func liveThinkingTail(content string, limit int) (string, bool) {
if limit <= 0 {
return content, false
}
start := len(content)
for range limit {
if start == 0 {
return content, false
}
_, size := utf8.DecodeLastRuneInString(content[:start])
start -= size
}
tail := content[start:]
if newline := strings.IndexByte(tail, '\n'); newline >= 0 {
tail = tail[newline+1:]
}
return tail, true
}
func thinkingStatusLine(entry chatEntry) string {
if entry.status != "running" {
return thoughtLabel(entry.tokenCount, entry.expanded)
}
label := "Thinking"
if strings.TrimSpace(entry.label) != "" {
label = entry.label
}
return label
}
func thoughtLabel(tokens int, expanded bool) string {
if !expanded || tokens <= 0 {
return "Thought"
}
return "Thought (" + formatTokenCount(tokens) + ")"
}
func (m chatModel) thinkingLabel() string {
@@ -696,32 +757,35 @@ func thinkingActivityLabel(tokens int) string {
return "Thinking"
}
func (m *chatModel) syncThinkingEntry() {
if strings.TrimSpace(m.latestLiveThinking()) == "" {
return
}
func (m *chatModel) syncThinkingEntry(content string) {
idx := -1
if len(m.entries) > 0 && m.entries[len(m.entries)-1].role == "thinking" && m.entries[len(m.entries)-1].status == "running" {
idx = len(m.entries) - 1
}
if idx < 0 {
if strings.TrimSpace(content) == "" {
return
}
m.entries = append(m.entries, newChatEntry(chatEntry{role: "thinking", status: "running"}))
idx = len(m.entries) - 1
}
m.entries[idx].content = m.latestLiveThinking()
m.entries[idx].content = content
m.entries[idx].label = m.thinkingLabel()
m.entries[idx].status = "running"
m.entries[idx].expanded = false
m.entries[idx].tokenCount = m.thinkingTokens
m.entries[idx].expanded = true
m.markEntryDirty(idx)
}
func (m chatModel) latestLiveThinking() string {
for i := len(m.liveMessages) - 1; i >= 0; i-- {
if m.liveMessages[i].Role == "assistant" && strings.TrimSpace(m.liveMessages[i].Thinking) != "" {
return m.liveMessages[i].Thinking
func (m *chatModel) applyThinkingDetails() {
for i := range m.entries {
entry := &m.entries[i]
if entry.role != "thinking" || entry.status == "running" || strings.TrimSpace(entry.content) == "" || entry.expanded == m.thinkingDetailsOpen {
continue
}
entry.expanded = m.thinkingDetailsOpen
m.markEntryDirty(i)
}
return ""
}
func (m *chatModel) finishThinkingEntry() {
@@ -734,6 +798,8 @@ func (m *chatModel) finishThinkingEntry() {
}
m.entries[idx].status = "done"
m.entries[idx].label = m.thinkingLabel()
m.entries[idx].tokenCount = m.thinkingTokens
m.entries[idx].expanded = m.thinkingDetailsOpen
m.markEntryDirty(idx)
}
@@ -1435,18 +1501,6 @@ func (m *chatModel) updateContextWindowTokens(tokens int) {
}
}
func (m chatModel) responseModelName(response *api.ChatResponse) string {
if response != nil {
if strings.TrimSpace(response.Model) != "" {
return response.Model
}
if strings.TrimSpace(response.RemoteModel) != "" {
return response.RemoteModel
}
}
return m.opts.Model
}
func (m chatModel) currentWorkingDir() string {
if strings.TrimSpace(m.workingDir) != "" {
return m.workingDir
@@ -1464,6 +1518,12 @@ func (m chatModel) activityLine() string {
if !m.running && !m.compacting && m.preloadingModel == "" && m.approvalPrompt == nil {
return ""
}
if m.thinking && len(m.entries) > 0 {
entry := m.entries[len(m.entries)-1]
if entry.role == "thinking" && entry.status == "running" {
return ""
}
}
label := m.activityLabel()
if label == "" {
if m.awaitingToolStart() {
@@ -1974,8 +2034,6 @@ func isInvisibleToolGroupingBoundary(entry chatEntry) bool {
strings.TrimSpace(entry.label) == "" &&
strings.TrimSpace(entry.detail) == "" &&
entry.metrics == nil
case "thinking":
return !entry.expanded
default:
return false
}
+309 -18
View File
@@ -8,6 +8,7 @@ import (
"strings"
"testing"
"time"
"unicode/utf8"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
@@ -548,6 +549,24 @@ func TestChatViewKeepsInputBoxWhileRunning(t *testing.T) {
}
}
func TestChatViewShowsActiveThinkingStatusOnce(t *testing.T) {
m := chatModel{
running: true,
thinking: true,
thinkingTokens: 42,
width: 60,
height: 16,
entries: []chatEntry{
{role: "thinking", label: "Thinking ↓ 42 tokens", status: "running", content: "streamed trace", expanded: true},
},
}
view := stripANSI(m.View())
if count := strings.Count(view, "Thinking ↓ 42 tokens"); count != 1 {
t.Fatalf("active thinking status appears %d times, want once:\n%s", count, view)
}
}
func TestChatToolFinishedUpdatesLiveWorkingDirOnly(t *testing.T) {
root := t.TempDir()
subdir := filepath.Join(root, "sub")
@@ -738,6 +757,107 @@ func TestChatStreamingAssistantOutputHoldsLiveMarkdown(t *testing.T) {
}
}
func TestChatStreamingRendersBoldBareURLAfterCompletion(t *testing.T) {
const response = "Draft PR opened: **https://github.com/ollama/ollama/pull/17203**"
m := chatModel{width: 80, height: 12, running: true, events: make(chan tea.Msg)}
updated, _ := m.Update(chatAgentMsg{event: coreagent.Event{Type: coreagent.EventMessageDelta, Content: "Draft PR opened: **https://github.com/ollama/"}})
m = updated.(chatModel)
if got := stripANSI(m.renderTranscript(80)); !strings.Contains(got, "**https://github.com/ollama/") {
t.Fatalf("incomplete Markdown should remain visible while streaming: %q", got)
}
updated, _ = m.Update(chatAgentMsg{event: coreagent.Event{Type: coreagent.EventMessageDelta, Content: "ollama/pull/17203**"}})
m = updated.(chatModel)
if got := m.entries[0].content; got != response {
t.Fatalf("streamed content = %q, want %q", got, response)
}
rendered := m.renderTranscript(80)
plain := stripANSI(rendered)
if strings.Contains(plain, "**") {
t.Fatalf("rendered response should not contain Markdown delimiters: %q", plain)
}
if !strings.Contains(plain, "Draft PR opened: https://github.com/ollama/ollama/pull/17203") {
t.Fatalf("rendered response missing URL: %q", plain)
}
if !strings.Contains(rendered, chatStrongStyle.Render("https://github.com/ollama/ollama/pull/17203")) {
t.Fatalf("URL should use the bold terminal style: %q", rendered)
}
}
func TestRenderMarkdownInlineWrapsStrongTextWithoutDelimiters(t *testing.T) {
rendered := renderMarkdownForView("**alpha beta gamma delta epsilon**", 20)
plain := stripANSI(rendered)
if strings.Contains(plain, "**") {
t.Fatalf("wrapped strong text should not contain Markdown delimiters: %q", plain)
}
for _, line := range strings.Split(rendered, "\n") {
if got := lipgloss.Width(line); got > 20 {
t.Fatalf("rendered line width = %d, want <= 20: %q", got, line)
}
}
}
func TestRenderMarkdownPreservesBareURLUnderscores(t *testing.T) {
const url = "https://example.com/a__b__"
if got := stripANSI(renderMarkdownForView(url, 80)); got != url {
t.Fatalf("bare URL = %q, want %q", got, url)
}
}
func TestRenderMarkdownStrongAfterPunctuation(t *testing.T) {
for _, test := range []struct {
name string
input string
want string
emphasis string
}{
{
name: "colon",
input: "Status: **ready**",
want: "Status: ready",
emphasis: "ready",
},
{
name: "dash",
input: "Note-**important**",
want: "Note-important",
emphasis: "important",
},
{
name: "closing parenthesis",
input: "Result) **complete**",
want: "Result) complete",
emphasis: "complete",
},
{
name: "identifier",
input: "value__with_delimiters__",
want: "value__with_delimiters__",
},
{
name: "URL",
input: "https://example.com/a__b__",
want: "https://example.com/a__b__",
},
{
name: "URL punctuation",
input: "https://example.com/a-**b**",
want: "https://example.com/a-**b**",
},
} {
t.Run(test.name, func(t *testing.T) {
rendered := renderMarkdownForView(test.input, 80)
if got := stripANSI(rendered); got != test.want {
t.Fatalf("rendered = %q, want %q", got, test.want)
}
if test.emphasis != "" && !strings.Contains(rendered, chatStrongStyle.Render(test.emphasis)) {
t.Fatalf("rendered output should emphasize %q: %q", test.emphasis, rendered)
}
})
}
}
func TestChatMouseWheelScrollsTranscriptWhileRunning(t *testing.T) {
m := chatModel{
width: 80,
@@ -1360,7 +1480,7 @@ func TestChatCompletedToolsGroupAcrossEmptyAssistantEntries(t *testing.T) {
}
}
func TestChatCompletedToolsGroupAcrossCollapsedThinking(t *testing.T) {
func TestChatCompletedToolsPreserveCollapsedThoughts(t *testing.T) {
entries := groupCompletedToolEntries([]chatEntry{
newChatEntry(chatEntry{role: "tool", detail: "bash", label: `Bash("pwd")`, status: "done", content: "one"}),
newChatEntry(chatEntry{role: "thinking", label: "Thinking", content: "choose next tool", status: "done"}),
@@ -1369,13 +1489,13 @@ func TestChatCompletedToolsGroupAcrossCollapsedThinking(t *testing.T) {
newChatEntry(chatEntry{role: "tool", detail: "read", label: `Read("AGENTS.md")`, status: "done", content: "instructions"}),
})
if len(entries) != 1 {
t.Fatalf("entries = %d, want one grouped tool entry: %#v", len(entries), entries)
if len(entries) != 3 {
t.Fatalf("entries = %d, want tool, thought, and grouped tools: %#v", len(entries), entries)
}
if entries[0].role != "tool_group" || len(entries[0].tools) != 3 {
t.Fatalf("tools should group across collapsed thinking: %#v", entries[0])
if entries[0].role != "tool" || entries[1].role != "thinking" || entries[2].role != "tool_group" || len(entries[2].tools) != 2 {
t.Fatalf("collapsed thought should separate tool groups: %#v", entries)
}
if line := stripANSI(toolGroupStatusLine(entries[0])); line != "Ran 2 commands and read a file" {
if line := stripANSI(toolGroupStatusLine(entries[2])); line != "Ran 1 command and read a file" {
t.Fatalf("grouped tool line = %q", line)
}
}
@@ -1442,29 +1562,161 @@ func TestChatCtrlOTogglesInlineOutput(t *testing.T) {
}
}
func TestChatCtrlODoesNotExpandThinking(t *testing.T) {
func TestChatCtrlOTogglesCompletedThinkingDetails(t *testing.T) {
m := chatModel{
entries: []chatEntry{
newChatEntry(chatEntry{role: "thinking", label: "Thinking", status: "done", content: "private reasoning"}),
newChatEntry(chatEntry{role: "thinking", label: "Thinking", status: "done", content: "private reasoning", tokenCount: 12}),
},
}
if view := stripANSI(m.renderTranscript(100)); !strings.Contains(view, "Thought") || strings.Contains(view, "12 tokens") {
t.Fatalf("collapsed thinking should hide its token count:\n%s", view)
}
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
m = updated.(chatModel)
if m.entries[0].expanded {
t.Fatalf("ctrl+o should not expand thinking entries: %#v", m.entries[0])
if !m.thinkingDetailsOpen || !m.entries[0].expanded {
t.Fatalf("ctrl+o should expand completed thinking entries: %#v", m.entries[0])
}
if view := stripANSI(m.renderTranscript(100)); strings.Contains(view, "private reasoning") {
t.Fatalf("ctrl+o should not render thinking content:\n%s", view)
if view := stripANSI(m.renderTranscript(100)); !strings.Contains(view, "Thought (12 tokens)") || !strings.Contains(view, "private reasoning") {
t.Fatalf("ctrl+o should render completed thinking content:\n%s", view)
}
m.liveMessages = []api.Message{{Role: "assistant", Thinking: "live private reasoning"}}
m.syncThinkingEntry()
if m.entries[1].expanded {
t.Fatalf("live thinking should not inherit ctrl+o expansion: %#v", m.entries[1])
m.syncThinkingEntry("live private reasoning")
if !m.entries[1].expanded {
t.Fatalf("live thinking should always be expanded: %#v", m.entries[1])
}
if view := stripANSI(m.renderTranscript(100)); strings.Contains(view, "live private reasoning") {
t.Fatalf("ctrl+o should not render live thinking content:\n%s", view)
if view := stripANSI(m.renderTranscript(100)); !strings.Contains(view, "live private reasoning") {
t.Fatalf("live thinking should render while streaming:\n%s", view)
}
m.thinkingTokens = 9
m.finishThinkingEntry()
if m.entries[1].status != "done" || !m.entries[1].expanded {
t.Fatalf("completed thinking should honor the open details mode: %#v", m.entries[1])
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
m = updated.(chatModel)
if m.thinkingDetailsOpen || m.entries[0].expanded || m.entries[1].expanded {
t.Fatalf("ctrl+o should collapse all completed thinking details: %#v", m.entries)
}
}
func TestChatThinkingBodyUsesSecondaryGrey(t *testing.T) {
for _, tt := range []struct {
entry chatEntry
header string
}{
{entry: chatEntry{role: "thinking", status: "running", content: "Let me inspect the files.", expanded: true}, header: "Thinking"},
{entry: chatEntry{role: "thinking", status: "done", content: "Let me inspect the files.", tokenCount: 15, expanded: true}, header: "Thought (15 tokens)"},
} {
lines := renderThinkingLines(tt.entry, 80)
if len(lines) < 3 {
t.Fatalf("thinking entry did not render its body: %#v", lines)
}
if lines[0] != tt.header {
t.Fatalf("thinking header = %q, want unmuted %q", lines[0], tt.header)
}
if got, want := lines[2], chatToolOutputStyle.Render("Let me inspect the files."); got != want {
t.Fatalf("thinking body = %q, want secondary grey %q", got, want)
}
}
}
func TestChatThinkingBodyAlignsWithStatusText(t *testing.T) {
m := chatModel{entries: []chatEntry{
{role: "thinking", status: "done", content: "Let me inspect the files.", tokenCount: 15, expanded: true},
}}
lines := strings.Split(stripANSI(m.renderTranscript(80)), "\n")
if len(lines) < 3 {
t.Fatalf("thinking entry did not render its body: %#v", lines)
}
if got, want := lines[0], "• Thought (15 tokens)"; got != want {
t.Fatalf("thinking header = %q, want %q", got, want)
}
if got, want := lines[2], " Let me inspect the files."; got != want {
t.Fatalf("thinking body = %q, want aligned with status text %q", got, want)
}
}
func TestChatLiveThinkingUsesBoundedSanitizedTail(t *testing.T) {
longThinking := "first-visible-marker\n" + strings.Repeat("x", maxLiveThinkingRunes+100) + "\nlast-visible-marker\x1b[2J"
m := chatModel{entries: []chatEntry{{role: "thinking", label: "Thinking ↓ 10 tokens", status: "running", content: longThinking, expanded: true}}}
view := stripANSI(m.renderTranscript(80))
if strings.Contains(view, "first-visible-marker") || !strings.Contains(view, "earlier thinking omitted while streaming") || !strings.Contains(view, "last-visible-marker") {
t.Fatalf("live thinking should render a bounded tail:\n%s", view)
}
if strings.Contains(view, "\x1b") || strings.Contains(view, "[2J") {
t.Fatalf("live thinking should remove terminal control sequences: %q", view)
}
}
func TestChatThinkingSanitizesTerminalControlsLiveAndReopened(t *testing.T) {
content := "safe\x1b]52;c;clipboard-payload\a visible\x1bPqdevice-control\x1b\\ done\b!\u009b31m red\a"
tests := []struct {
name string
entry chatEntry
}{
{name: "live", entry: chatEntry{role: "thinking", status: "running", content: content, expanded: true}},
{name: "reopened", entry: chatEntry{role: "thinking", status: "done", content: content, expanded: true}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
view := chatModel{entries: []chatEntry{tt.entry}}.renderTranscript(80)
plain := stripANSI(view)
if !strings.Contains(plain, "safe") || !strings.Contains(plain, "visible") || !strings.Contains(plain, "done!") || !strings.Contains(plain, "red") {
t.Fatalf("sanitized thinking lost visible text: %q", plain)
}
for _, unsafe := range []string{"clipboard-payload", "device-control", "\x1b]", "\x1bP", "\a", "\b", "\u009b"} {
if strings.Contains(view, unsafe) {
t.Fatalf("sanitized thinking contains unsafe value %q: %q", unsafe, view)
}
}
})
}
}
func TestLiveThinkingTailPreservesUTF8Boundary(t *testing.T) {
content := strings.Repeat("🙂", maxLiveThinkingRunes+100)
tail, omitted := liveThinkingTail(content, maxLiveThinkingRunes)
if !omitted {
t.Fatal("long thinking trace should report an omitted prefix")
}
if !utf8.ValidString(tail) || utf8.RuneCountInString(tail) != maxLiveThinkingRunes {
t.Fatalf("tail should contain %d complete runes, got %d", maxLiveThinkingRunes, utf8.RuneCountInString(tail))
}
}
func BenchmarkRenderLiveThinkingLines(b *testing.B) {
content := strings.Repeat("old thinking that is outside the visible tail\n", 100_000) + "visible tail"
entry := chatEntry{role: "thinking", status: "running", content: content, expanded: true}
b.ReportAllocs()
for b.Loop() {
renderThinkingLines(entry, 80)
}
}
func TestChatThinkingDetailsRespectNarrowAndResizedViews(t *testing.T) {
m := chatModel{
width: 80,
height: 10,
entries: []chatEntry{
{role: "thinking", label: "Thinking ↓ 42 tokens", status: "running", content: "first delta\nsecond delta", expanded: true},
},
}
updated, _ := m.Update(tea.WindowSizeMsg{Width: 24, Height: 8})
m = updated.(chatModel)
transcript := stripANSI(m.renderTranscript(24))
if !strings.Contains(transcript, "Thinking") || !strings.Contains(transcript, "second delta") {
t.Fatalf("narrow transcript should retain the live thinking tail:\n%s", transcript)
}
if len(m.transcriptLines(24)) == 0 {
t.Fatal("resized transcript should remain selectable and scrollable")
}
}
@@ -1881,7 +2133,10 @@ func TestChatToolCallRendersPrettyInvocationAndResult(t *testing.T) {
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
m = updated.(chatModel)
view := stripANSI(m.renderTranscript(100))
if !strings.Contains(view, "**Search results for:**") || !strings.Contains(view, "https://parthsareen.com") {
if strings.Contains(view, "**") {
t.Fatalf("inline web output should render Markdown, not show delimiters: %q", view)
}
if !strings.Contains(view, "Search results for:") || !strings.Contains(view, "https://parthsareen.com") {
t.Fatalf("inline web output missing content: %q", view)
}
}
@@ -1971,3 +2226,39 @@ func TestRenderMarkdownTableWrapsLongCells(t *testing.T) {
}
}
}
func TestRenderMarkdownProseWithPipeExamplesIsNotTable(t *testing.T) {
markdown := strings.Join([]string{
"- **Regression confirmed** vs. `fdbe8d33`: the bare `< | open | >` remains prose.",
"| | |",
"- **Severity**: `< | close | >` is another inline example.",
}, "\n")
rendered := renderMarkdownForView(markdown, 120)
plain := stripANSI(rendered)
if !strings.Contains(plain, "| | |") || !strings.Contains(plain, "the bare < | open | > remains") || !strings.Contains(plain, "< | close | > is another") {
t.Fatalf("pipe-delimited prose rendered as a table:\n%s", plain)
}
if !strings.Contains(rendered, chatStrongStyle.Render("Regression confirmed")) {
t.Fatalf("bold prose was not emphasized: %q", rendered)
}
if !strings.Contains(rendered, chatInlineCodeStyle.Render("fdbe8d33")) {
t.Fatalf("inline code was not styled: %q", rendered)
}
}
func TestRenderMarkdownTablePreservesValidSeparator(t *testing.T) {
markdown := strings.Join([]string{
"| Name | State |",
"| --- | :---: |",
"| Ollama | Ready |",
}, "\n")
plain := stripANSI(renderMarkdownForView(markdown, 80))
if strings.Contains(plain, "---") {
t.Fatalf("table separator should not render as prose:\n%s", plain)
}
if !strings.Contains(plain, "Name") || !strings.Contains(plain, "Ollama") {
t.Fatalf("valid Markdown table was not rendered:\n%s", plain)
}
}
+3
View File
@@ -45,6 +45,9 @@ var (
chatInlineCodeStyle = lipgloss.NewStyle().
Bold(true)
chatStrongStyle = lipgloss.NewStyle().
Bold(true)
chatCodeBlockStyle = lipgloss.NewStyle()
chatTableBorderStyle = lipgloss.NewStyle().
+80 -12
View File
@@ -42,6 +42,7 @@ type menuItem struct {
description string
integration string
isRunModel bool
isOthers bool
}
var runModelMenuItem = menuItem{
@@ -50,33 +51,61 @@ var runModelMenuItem = menuItem{
isRunModel: true,
}
// launcherMenuIntegrations is intentionally short: the root ollama command is
// a quick path to the most common launch targets. Other registered
// integrations remain available through `ollama launch <integration>`.
var othersMenuItem = menuItem{
title: "More...",
description: "Show additional integrations",
isOthers: true,
}
// launcherMenuIntegrations defines the integrations pinned to the root menu.
// Additional visible integrations are available through More in registry order.
var launcherMenuIntegrations = []string{"claude", "opencode", "hermes", "openclaw"}
type model struct {
state *launch.LauncherState
items []menuItem
cursor int
width int
quitting bool
selected bool
action TUIAction
state *launch.LauncherState
items []menuItem
cursor int
showOthers bool
width int
quitting bool
selected bool
action TUIAction
}
func newModel(state *launch.LauncherState) model {
m := model{
state: state,
}
m.items = buildMenuItems(state)
m.showOthers = shouldExpandOthers(state)
m.items = buildMenuItems(state, m.showOthers)
m.cursor = initialCursor(state, m.items)
return m
}
func buildMenuItems(state *launch.LauncherState) []menuItem {
func shouldExpandOthers(state *launch.LauncherState) bool {
if state == nil {
return false
}
for _, item := range otherIntegrationItems(state) {
if item.integration == state.LastSelection {
return true
}
}
return false
}
func buildMenuItems(state *launch.LauncherState, showOthers bool) []menuItem {
items := []menuItem{runModelMenuItem}
items = append(items, launcherIntegrationItems(state)...)
otherItems := otherIntegrationItems(state)
switch {
case showOthers:
items = append(items, otherItems...)
case len(otherItems) > 0:
items = append(items, othersMenuItem)
}
return items
}
@@ -108,6 +137,34 @@ func launcherIntegrationItems(state *launch.LauncherState) []menuItem {
return items
}
func otherIntegrationItems(state *launch.LauncherState) []menuItem {
if state == nil {
return nil
}
pinned := make(map[string]bool, len(launcherMenuIntegrations))
for _, name := range launcherMenuIntegrations {
pinned[name] = true
}
items := make([]menuItem, 0, len(state.Integrations))
for _, info := range launch.ListIntegrationInfos() {
if pinned[info.Name] {
continue
}
integrationState, ok := state.Integrations[info.Name]
if !ok {
continue
}
items = append(items, integrationMenuItem(integrationState))
}
return items
}
func primaryMenuItemCount(state *launch.LauncherState) int {
return 1 + len(launcherIntegrationItems(state))
}
func initialCursor(state *launch.LauncherState, items []menuItem) int {
if state == nil || state.LastSelection == "" {
return 0
@@ -143,12 +200,21 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.cursor > 0 {
m.cursor--
}
if m.showOthers && m.cursor < primaryMenuItemCount(m.state) {
m.showOthers = false
m.items = buildMenuItems(m.state, false)
m.cursor = min(m.cursor, len(m.items)-1)
}
return m, nil
case "down", "j":
if m.cursor < len(m.items)-1 {
m.cursor++
}
if m.cursor < len(m.items) && m.items[m.cursor].isOthers && !m.showOthers {
m.showOthers = true
m.items = buildMenuItems(m.state, true)
}
return m, nil
case "enter", " ":
@@ -231,6 +297,8 @@ func (m model) renderMenuItem(index int, item menuItem) string {
if m.cursor == index {
style = menuSelectedItemStyle
}
} else if item.isOthers {
// More immediately expands when reached, so it always uses the default style.
} else {
integrationState := m.state.Integrations[item.integration]
if !integrationState.Selectable {
+73 -2
View File
@@ -91,6 +91,8 @@ func integrationSequence(items []menuItem) []string {
switch {
case item.isRunModel:
sequence = append(sequence, "run")
case item.isOthers:
sequence = append(sequence, "more")
case item.integration != "":
sequence = append(sequence, item.integration)
}
@@ -105,7 +107,7 @@ func compareStrings(got, want []string) string {
func TestMenuRendersRootLaunchChoices(t *testing.T) {
state := launcherTestState()
menu := newModel(state)
want := []string{"run", "claude", "opencode", "hermes", "openclaw"}
want := []string{"run", "claude", "opencode", "hermes", "openclaw", "more"}
if diff := compareStrings(integrationSequence(menu.items), want); diff != "" {
t.Fatalf("unexpected root launch choices: %s", diff)
}
@@ -118,18 +120,87 @@ func TestMenuRendersRootLaunchChoices(t *testing.T) {
"Launch OpenCode",
"Launch Hermes Agent",
"Launch OpenClaw",
"More...",
} {
if !strings.Contains(view, want) {
t.Fatalf("expected menu view to contain %q\n%s", want, view)
}
}
for _, hidden := range []string{"Launch ChatGPT", "Launch Codex", "Launch Droid", "Launch Pi", "More..."} {
for _, hidden := range []string{"Launch ChatGPT", "Launch Codex", "Launch Droid", "Launch Pi"} {
if strings.Contains(view, hidden) {
t.Fatalf("expected root menu to omit %q\n%s", hidden, view)
}
}
}
func TestMenuExpandsMoreOnDownNavigation(t *testing.T) {
state := launcherTestState()
menu := newModel(state)
menu.cursor = findMenuCursorByIntegration(menu.items, "openclaw")
if menu.cursor == -1 {
t.Fatal("expected openclaw menu item")
}
updated, _ := menu.Update(tea.KeyMsg{Type: tea.KeyDown})
got := updated.(model)
if !got.showOthers {
t.Fatal("expected navigating down onto More to expand additional integrations")
}
if got.items[got.cursor].integration == "" {
t.Fatalf("expected cursor to land on the first additional integration, got %#v", got.items[got.cursor])
}
if strings.Contains(got.View(), "More...") {
t.Fatalf("expected expanded integrations to replace More\n%s", got.View())
}
}
func TestMenuStartsExpandedForPreviousOverflowSelection(t *testing.T) {
state := launcherTestState()
overflow := otherIntegrationItems(state)
if len(overflow) < 2 {
t.Fatal("expected at least two additional integrations")
}
state.LastSelection = overflow[1].integration
menu := newModel(state)
if !menu.showOthers {
t.Fatal("expected previous additional integration selection to start expanded")
}
if got := menu.items[menu.cursor].integration; got != state.LastSelection {
t.Fatalf("initial cursor integration = %q, want %q", got, state.LastSelection)
}
if strings.Contains(menu.View(), "More...") {
t.Fatalf("expected expanded menu to omit More\n%s", menu.View())
}
}
func TestMenuOmitsMoreWithoutAdditionalIntegrations(t *testing.T) {
state := launcherTestState()
for name := range state.Integrations {
if name != "claude" && name != "opencode" && name != "hermes" && name != "openclaw" {
delete(state.Integrations, name)
}
}
state.Integrations["claude-desktop"] = launch.LauncherIntegrationState{
Name: "claude-desktop",
DisplayName: "Claude Desktop",
Selectable: true,
Changeable: true,
}
menu := newModel(state)
want := []string{"run", "claude", "opencode", "hermes", "openclaw"}
if diff := compareStrings(integrationSequence(menu.items), want); diff != "" {
t.Fatalf("unexpected menu without additional integrations: %s", diff)
}
if strings.Contains(menu.View(), "More...") {
t.Fatalf("expected no More item without additional integrations\n%s", menu.View())
}
if strings.Contains(menu.View(), "Claude Desktop") {
t.Fatalf("expected hidden integration to remain omitted\n%s", menu.View())
}
}
func TestMenuEnterOnRunSelectsRun(t *testing.T) {
menu := newModel(launcherTestState())
updated, _ := menu.Update(tea.KeyMsg{Type: tea.KeyEnter})
+1 -1
View File
@@ -19,5 +19,5 @@
### Resources
* [Troubleshooting Guide](https://docs.ollama.com/troubleshooting)
* [FAQ](https://docs.ollama.com/faq#faq)
* [FAQ](https://docs.ollama.com/faq)
* [Development guide](./development.md)
+3 -63
View File
@@ -16,7 +16,6 @@
- [Generate Embeddings](#generate-embeddings)
- [List Running Models](#list-running-models)
- [Version](#version)
- [Experimental: Image Generation](#image-generation-experimental)
## Conventions
@@ -59,15 +58,6 @@ Advanced parameters (optional):
- `keep_alive`: controls how long the model will stay loaded into memory following the request (default: `5m`)
- `context` (deprecated): the context parameter returned from a previous request to `/generate`, this can be used to keep a short conversational memory
Experimental image generation parameters (for image generation models only):
> [!WARNING]
> These parameters are experimental and may change in future versions.
- `width`: width of the generated image in pixels
- `height`: height of the generated image in pixels
- `steps`: number of diffusion steps
#### Structured outputs
Structured outputs are supported by providing a JSON schema in the `format` parameter. The model will generate a response that matches the schema. See the [structured outputs](#request-structured-outputs) example below.
@@ -1189,7 +1179,7 @@ Create a model from:
- a safetensors directory; or
- a GGUF file.
If you are creating a model from a safetensors directory or from a GGUF file, you must [create a blob](#create-a-blob) for each of the files and then use the file name and SHA256 digest associated with each blob in the `files` field.
If you are creating a model from a safetensors directory or from a GGUF file, you must [push a blob](#push-a-blob) for each of the files and then use the file name and SHA256 digest associated with each blob in the `files` field.
### Parameters
@@ -1198,6 +1188,8 @@ If you are creating a model from a safetensors directory or from a GGUF file, yo
- `files`: (optional) a dictionary of file names to SHA256 digests of blobs to create the model from
- `adapters`: (optional) a dictionary of file names to SHA256 digests of blobs for LORA adapters
- `template`: (optional) the prompt template for the model
- `renderer`: (optional) the name of the renderer for the model
- `parser`: (optional) the name of the parser for the model
- `license`: (optional) a string or list of strings containing the license or licenses for the model
- `system`: (optional) a string containing the system prompt for the model
- `parameters`: (optional) a dictionary of parameters for the model (see [Modelfile](./modelfile.mdx#valid-parameters-and-values) for a list of parameters)
@@ -1878,55 +1870,3 @@ curl http://localhost:11434/api/version
"version": "0.5.1"
}
```
## Experimental Features
### Image Generation (Experimental)
> [!WARNING]
> Image generation is experimental and may change in future versions.
Image generation is now supported through the standard `/api/generate` endpoint when using image generation models. The API automatically detects when an image generation model is being used.
See the [Generate a completion](#generate-a-completion) section for the full API documentation. The experimental image generation parameters (`width`, `height`, `steps`) are documented there.
#### Example
##### Request
```shell
curl http://localhost:11434/api/generate -d '{
"model": "x/z-image-turbo",
"prompt": "a sunset over mountains",
"width": 1024,
"height": 768
}'
```
##### Response (streaming)
Progress updates during generation:
```json
{
"model": "x/z-image-turbo",
"created_at": "2024-01-15T10:30:00.000000Z",
"completed": 5,
"total": 20,
"done": false
}
```
##### Final Response
```json
{
"model": "x/z-image-turbo",
"created_at": "2024-01-15T10:30:15.000000Z",
"image": "iVBORw0KGgoAAAANSUhEUg...",
"done": true,
"done_reason": "stop",
"total_duration": 15000000000,
"load_duration": 2000000000
}
```
+2 -2
View File
@@ -16,7 +16,7 @@ After installation, Ollama's API is served by default at:
http://localhost:11434/api
```
For running cloud models on **ollama.com**, the same API is available with the following base URL:
For running cloud models on [ollama.com](https://ollama.com), the same API is available with the following base URL:
```
https://ollama.com/api
@@ -40,7 +40,7 @@ Ollama has official libraries for Python and JavaScript:
- [Python](https://github.com/ollama/ollama-python)
- [JavaScript](https://github.com/ollama/ollama-js)
Several community-maintained libraries are available for Ollama. For a full list, see the [Ollama GitHub repository](https://github.com/ollama/ollama?tab=readme-ov-file#libraries-1).
Several community-maintained libraries are available for Ollama. For a full list, see the [Ollama GitHub repository](https://github.com/ollama/ollama?tab=readme-ov-file#libraries--sdks).
## Versioning
-67
View File
@@ -279,73 +279,6 @@ curl -X POST http://localhost:11434/v1/chat/completions \
- [x] `dimensions`
- [ ] `user`
### `/v1/images/generations` (experimental)
> Note: This endpoint is experimental and may change or be removed in future versions.
Generate images using image generation models.
<CodeGroup dropdown>
```python images.py
from openai import OpenAI
client = OpenAI(
base_url='http://localhost:11434/v1/',
api_key='ollama', # required but ignored
)
response = client.images.generate(
model='x/z-image-turbo',
prompt='A cute robot learning to paint',
size='1024x1024',
response_format='b64_json',
)
print(response.data[0].b64_json[:50] + '...')
```
```javascript images.js
import OpenAI from "openai";
const openai = new OpenAI({
baseURL: "http://localhost:11434/v1/",
apiKey: "ollama", // required but ignored
});
const response = await openai.images.generate({
model: "x/z-image-turbo",
prompt: "A cute robot learning to paint",
size: "1024x1024",
response_format: "b64_json",
});
console.log(response.data[0].b64_json.slice(0, 50) + "...");
```
```shell images.sh
curl -X POST http://localhost:11434/v1/images/generations \
-H "Content-Type: application/json" \
-d '{
"model": "x/z-image-turbo",
"prompt": "A cute robot learning to paint",
"size": "1024x1024",
"response_format": "b64_json"
}'
```
</CodeGroup>
#### Supported request fields
- [x] `model`
- [x] `prompt`
- [x] `size` (e.g. "1024x1024")
- [x] `response_format` (only `b64_json` supported)
- [ ] `n`
- [ ] `quality`
- [ ] `style`
- [ ] `user`
### `/v1/responses`
> Note: Added in Ollama v0.13.3
+24 -17
View File
@@ -229,7 +229,7 @@ curl https://ollama.com/api/chat \
## Local only
Ollama can run in local-only mode by [disabling Ollama's cloud](./faq#how-do-i-disable-ollama-cloud) features.
Ollama can run in local-only mode by [disabling Ollama's cloud](./faq#how-do-i-disable-ollama-cloud-features) features.
## Retirements
@@ -244,26 +244,33 @@ Ollama Cloud model retirement does not affect local models.
| Retirement date | Model | Recommended alternative |
| --- | --- | --- |
| July 15, 2026 | `deepseek-v3.1:671b` | `deepseek-v4-flash` |
| July 15, 2026 | `deepseek-v3.2` | `deepseek-v4-flash` |
| July 15, 2026 | `devstral-2:123b` | `mistral-large-3:675b` |
| July 15, 2026 | `devstral-small-2:24b` | |
| July 15, 2026 | `ministral-3:14b` | |
| July 15, 2026 | `ministral-3:3b` | |
| July 15, 2026 | `ministral-3:8b` | |
| July 15, 2026 | `gemini-3-flash-preview` | `minimax-m3` |
| July 15, 2026 | `gemma3:12b` | `gemma4:31b` |
| July 15, 2026 | `gemma3:27b` | `gemma4:31b` |
| July 15, 2026 | `gemma3:4b` | `gemma4:31b` |
| July 15, 2026 | `glm-4.7` | `glm-5.2` |
| July 15, 2026 | `glm-5` | `glm-5.2` |
| July 15, 2026 | `minimax-m2.1` | `minimax-m3` |
| July 15, 2026 | `qwen3-coder-next` | `qwen3.5:397b` |
| July 15, 2026 | `qwen3-coder:480b` | `qwen3.5:397b` |
| July 31, 2026 | `minimax-m2.5` | `minimax-m2.7` |
| July 31, 2026 | `kimi-k2.5` | `kimi-k2.6` |
### Past retirements
<AccordionGroup>
<Accordion title="July 15, 2026">
| Model | Recommended alternative |
| --- | --- |
| `deepseek-v3.1:671b` | `deepseek-v4-flash` |
| `deepseek-v3.2` | `deepseek-v4-flash` |
| `devstral-2:123b` | `mistral-large-3:675b` |
| `devstral-small-2:24b` | |
| `ministral-3:14b` | |
| `ministral-3:3b` | |
| `ministral-3:8b` | |
| `gemini-3-flash-preview` | `minimax-m3` |
| `gemma3:12b` | `gemma4:31b` |
| `gemma3:27b` | `gemma4:31b` |
| `gemma3:4b` | `gemma4:31b` |
| `glm-4.7` | `glm-5.2` |
| `glm-5` | `glm-5.2` |
| `minimax-m2.1` | `minimax-m3` |
| `qwen3-coder-next` | `qwen3.5:397b` |
| `qwen3-coder:480b` | `qwen3.5:397b` |
</Accordion>
<Accordion title="June 30, 2026">
| Model | Recommended alternative |
| --- | --- |
+21
View File
@@ -60,6 +60,26 @@
}
},
"redirects": [
{
"source": "/development.md",
"destination": "/development"
},
{
"source": "/api/openai-compatibility.mdx",
"destination": "/api/openai-compatibility"
},
{
"source": "/gpu.mdx",
"destination": "/gpu"
},
{
"source": "/integrations/vscode.mdx",
"destination": "/integrations/vscode"
},
{
"source": "/troubleshooting.mdx",
"destination": "/troubleshooting"
},
{
"source": "/openai",
"destination": "/api/openai-compatibility"
@@ -148,6 +168,7 @@
"pages": [
"/integrations/claude-code",
"/integrations/opencode",
"/integrations/deepseek-harness",
"/integrations/cline-cli",
"/integrations/codex-app",
"/integrations/codex",
+1 -1
View File
@@ -11,4 +11,4 @@ Ollama JavaScript examples at [ollama-js/examples](https://github.com/ollama/oll
## OpenAI compatibility examples
Ollama OpenAI compatibility examples at [ollama/examples/openai](../docs/openai.md)
Ollama OpenAI compatibility examples at [ollama/examples/openai](./api/openai-compatibility.mdx)
+2 -2
View File
@@ -160,7 +160,7 @@ docker run -d -e HTTPS_PROXY=https://my.proxy.example.com -p 11434:11434 ollama-
Ollama runs locally. We don't see your prompts or data when you run locally. When using cloud-hosted models, we process your prompts and responses to provide the service but do not store or log that content and never train on it. We collect basic account info and limited usage metadata to provide the service that does not include prompt or response content. We don't sell your data. You can delete your account anytime.
## How do I disable Ollama's cloud features?
## How do I disable Ollama Cloud features?
Ollama can run in local only mode by disabling Ollama's cloud features. By turning off Ollama's cloud features, you will lose the ability to use Ollama's cloud models and web search.
@@ -248,7 +248,7 @@ Refer to the section [above](#how-do-i-configure-ollama-server) for how to set e
## How can I use Ollama in Visual Studio Code?
There is already a large collection of plugins available for VS Code as well as other editors that leverage Ollama. See the list of [extensions & plugins](https://github.com/ollama/ollama#extensions--plugins) at the bottom of the main repository readme.
Install the [Ollama extension](https://marketplace.visualstudio.com/items?itemName=Ollama.ollama) to use Ollama models in VS Code Chat. See the [VS Code integration guide](./integrations/vscode.mdx) for setup and troubleshooting.
## How do I use Ollama with GPU acceleration in Docker?
+1 -1
View File
@@ -35,7 +35,7 @@ Check your compute compatibility to see if your card is supported:
| 5.0 | GeForce GTX | `GTX 750 Ti` `GTX 750` `NVS 810` |
| | Quadro | `K2200` `K1200` `K620` `M1200` `M520` `M5000M` `M4000M` `M3000M` `M2000M` `M1000M` `K620M` `M600M` `M500M` |
For building locally to support older GPUs, see [developer](./development#linux-cuda-nvidia)
For building locally to support older GPUs, see [development](./development.md).
### GPU Selection
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50" fill="none">
<style>@media (prefers-color-scheme: dark) { path { fill: #fff; } }</style>
<path d="M48.8354 10.0479C48.3232 9.79199 48.1025 10.2798 47.8032 10.5278C46.7793 11.624 45.9048 12.1597 44.7622 12.0957C43.0923 12 41.666 12.5356 40.4058 13.8398C40.1377 12.2319 39.2476 11.272 37.8926 10.6558C36.4668 10.0156 35.9702 9.31982 35.356 7.72754C35.2456 7.3999 35.1353 7.06396 34.7651 7.00781C34.3633 6.94385 34.2056 7.2876 34.0479 7.57568C33.418 8.75195 33.1733 10.0479 33.1973 11.3599C33.2524 14.312 34.4736 16.6641 36.8999 18.3359C37.1758 18.5278 37.2466 18.7197 37.1597 19C36.9946 19.5757 36.7974 20.1357 36.624 20.7119C36.5137 21.0801 36.3486 21.1597 35.9624 21C32.4092 19.4878 30.0381 16.2319 27.2334 13.52C26.7764 13.1758 26.3193 12.856 25.8467 12.5518C23.8618 10.584 26.1069 8.96777 26.627 8.77588C27.1704 8.57568 26.8159 7.8877 25.0591 7.896C22.8691 7.90381 20.4507 9.06396 18.7095 9.58398C16.8501 9.22363 14.9199 9.14355 12.9033 9.37598C5.30859 10.2397 1.15674 16.4717 1.30664 27.2559C2.11768 31.9521 4.46582 35.8398 8.07373 38.8799C11.8159 42.0322 16.1255 43.5762 21.041 43.2803C24.0269 43.104 27.3516 42.6963 31.1016 39.4561C33.0396 40.1279 37.1758 40.208 38.1211 40.0078C39.6021 39.688 39.4995 38.2881 38.9639 38.0322C34.623 35.9678 35.5762 36.8081 34.71 36.1279C36.9155 33.4639 40.2402 30.6958 41.54 21.728C41.6426 21.0161 41.5557 20.5679 41.54 19.9917C41.5322 19.6396 41.6108 19.5039 42.0049 19.4639C46.6924 18.9116 49.064 15.9038 49.3315 11.2559C49.3711 10.7837 49.3237 10.2959 48.8354 10.0479ZM24.3262 37.8398C20.1196 34.4639 18.0791 33.3521 17.2358 33.3999C16.4482 33.4482 16.5898 34.3682 16.7632 34.9678C16.9443 35.5601 17.1812 35.9683 17.5117 36.4878C17.7402 36.832 17.8979 37.3442 17.2832 37.728C15.9282 38.584 13.5728 37.4399 13.4624 37.3838C7.97949 34.0879 4.48926 28.9282 4.19775 21.3677C4.1582 20.5757 4.38672 20.2959 5.15869 20.1519C11.8945 18.8799 17.165 22.0879 19.2529 25.7759C23.5381 30.104 25.335 35.1523 30.479 39.104C28.8643 39.2881 26.1699 39.3281 24.3262 37.8398ZM26.3433 24.6001C26.3433 24.248 26.6191 23.9678 26.9658 23.9678C27.3042 23.9678 27.5801 24.248 27.5801 24.6001C27.5801 24.9521 27.3042 25.2319 26.9575 25.2319C26.6108 25.2319 26.3433 24.9521 26.3433 24.6001ZM32.6064 27.8799C31.6372 28.2881 30.6289 28.3042 29.8096 27.688C28.6987 26.8555 28.6279 25.7759 28.7305 24.9199C28.8721 24.248 28.7144 23.8159 28.2495 23.4238C27.8716 23.104 27.3911 23.0161 26.8633 23.0161C26.666 23.0161 26.4849 22.9277 26.3511 22.856C25.8467 22.5762 25.9805 22.1758 26.5088 21.688C28.0996 20.7598 29.6362 21.9917 30.834 23.3281C31.6216 24.2559 32.8901 26.312 33.1104 26.9521C33.2446 27.3521 33.0713 27.6802 32.6064 27.8799Z" fill="#000"/>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

+4 -4
View File
@@ -4,10 +4,10 @@ title: Importing a Model
## Table of Contents
- [Importing a Safetensors adapter](#Importing-a-fine-tuned-adapter-from-Safetensors-weights)
- [Importing a Safetensors model](#Importing-a-model-from-Safetensors-weights)
- [Importing a GGUF file](#Importing-a-GGUF-based-model-or-adapter)
- [Sharing models on ollama.com](#Sharing-your-model-on-ollamacom)
- [Importing a Safetensors adapter](#importing-a-fine-tuned-adapter-from-safetensors-weights)
- [Importing a Safetensors model](#importing-a-model-from-safetensors-weights)
- [Importing a GGUF file](#importing-a-gguf-based-model-or-adapter)
- [Sharing models on ollama.com](#sharing-your-model-on-ollama-com)
## Importing a fine tuned adapter from Safetensors weights
+12
View File
@@ -76,3 +76,15 @@ Then run:
```
codex --profile ollama-launch
```
## Web search
Codex web-search requests sent through the Ollama profile are executed by
Ollama for both local and cloud models. Sign in with `ollama signin` to use the
web-search service.
To disable web search for a Codex session:
```shell
codex --profile ollama-launch -c 'web_search="disabled"'
```
+50
View File
@@ -0,0 +1,50 @@
---
title: DeepSeek Harness
---
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) is an open-source coding agent.
<Note>DeepSeek Harness is currently a developer preview. Its upstream configuration may change between releases.</Note>
## Setup
```shell
ollama launch dsh
```
Ollama installs `@deepseek-ai/dsh` if needed. To choose a model:
```shell
ollama launch dsh --model qwen3.8
ollama launch dsh --model deepseek-v4-flash:cloud
```
To configure without starting:
```shell
ollama launch dsh --config
```
## Web search
Web search is enabled automatically. It requires Ollama cloud access and a model that supports tools. Run `ollama signin` if needed.
## Configuration
Ollama stores its settings in `~/.ollama/launch/dsh/settings.yaml`. These settings load last and set the model, provider, and web search connection. Repeated launches preserve other settings in this file. Ollama does not change `~/.dsh/settings.yaml`, profiles, sessions, or credentials.
Launch rejects additional `--patch` arguments. Pass other Harness arguments after `--`:
```shell
ollama launch dsh -- --port 3081
```
## Manual install
DeepSeek Harness requires Node.js. To install it manually:
```shell
npm install -g @deepseek-ai/dsh@latest
```
Then run `ollama launch dsh`. On Windows, install Node.js for Windows.
+2 -2
View File
@@ -4,7 +4,7 @@ title: Goose
## Goose Desktop
Install [Goose](https://block.github.io/goose/docs/getting-started/installation/) Desktop.
Install [Goose](https://goose-docs.ai/docs/getting-started/installation/) Desktop.
### Usage with Ollama
1. In Goose, open **Settings** → **Configure Provider**.
@@ -27,7 +27,7 @@ Install [Goose](https://block.github.io/goose/docs/getting-started/installation/
## Goose CLI
Install [Goose](https://block.github.io/goose/docs/getting-started/installation/) CLI
Install [Goose](https://goose-docs.ai/docs/getting-started/installation/) CLI
### Usage with Ollama
1. Run `goose configure`
+11 -1
View File
@@ -14,7 +14,7 @@ ollama launch hermes-desktop
Ollama handles the setup flow automatically:
1. **Install** - If Hermes Desktop isn't installed, Ollama prompts to install it
1. **Install** - If Hermes isn't installed, Ollama prompts to install the Hermes command-line agent. On first desktop launch, Hermes builds its packaged desktop app.
2. **Model** - Pick a model from the selector
3. **Configure** - Ollama configures Hermes Desktop to use your selected Ollama model
4. **Launch** - Ollama opens Hermes Desktop
@@ -26,3 +26,13 @@ ollama launch hermes-desktop --model <model>
```
Run `ollama launch hermes-desktop` again to switch models later.
## Install Hermes Desktop directly
On macOS and Windows, the Hermes Desktop installer is the recommended upstream installation path. It installs the desktop app and Hermes Agent together. If you prefer the command line, `ollama launch hermes-desktop` remains the explicit Ollama-managed path and uses the same Hermes configuration, sessions, skills, and memory as the CLI.
To force Hermes to rebuild its packaged desktop app:
```bash
ollama launch hermes-desktop -- --force-build
```
+4 -5
View File
@@ -14,7 +14,7 @@ ollama launch hermes
Ollama handles everything automatically:
1. **Install** — If Hermes isn't installed, Ollama prompts to install it via the Nous Research install script
1. **Install** — If Hermes isn't installed, Ollama prompts to install the Hermes command-line agent
2. **Model** — Pick a model from the selector (local or cloud)
3. **Onboarding** — Ollama configures the Ollama provider, points Hermes at `http://127.0.0.1:11434/v1`, and sets your model as the primary
4. **Gateway** — Optionally connects a messaging platform (Telegram, Discord, Slack, WhatsApp, Signal, Email) and launches the Hermes chat
@@ -45,10 +45,10 @@ hermes gateway setup
## Reconfigure
Re-run the full setup wizard at any time:
Use Hermes's model picker to change providers or models later:
```bash
hermes setup
hermes model
```
## Manual setup
@@ -106,7 +106,7 @@ Optionally connect a messaging platform during setup:
Connect a messaging platform? (Telegram, Discord, etc.)
→ Set up messaging now (recommended)
Skip — set up later with 'hermes setup gateway'
Skip — set up later with 'hermes gateway setup'
```
### Launch
@@ -114,4 +114,3 @@ Connect a messaging platform? (Telegram, Discord, etc.)
```
Launch hermes chat now? [Y/n]: Y
```
+4
View File
@@ -17,6 +17,10 @@ Run `ollama launch` to see the latest integrations you can run from the terminal
<Card title="OpenCode" icon="/images/launch-icons/opencode.svg" href="/integrations/opencode">
Open-source coding agent that edits, runs, and iterates on code.
</Card>
<Card title="DeepSeek Harness" icon="/images/launch-icons/deepseek-harness.svg" href="/integrations/deepseek-harness">
DeepSeek's open-source agent harness with subagents and web search.
</Card>
</CardGroup>
## Connect an assistant
+9 -1
View File
@@ -6,10 +6,12 @@ Use Ollama models in VS Code Chat with the [Ollama extension](https://marketplac
## Requirements
- [Visual Studio Code 1.120 or newer](https://code.visualstudio.com/download)
- [Visual Studio Code 1.127 or newer](https://code.visualstudio.com/download)
- Ollama installed and running
- At least one local or cloud model available in Ollama
Earlier VS Code versions do not reliably cancel requests from language model providers.
Ollama 0.17.6 or newer is recommended for cloud model sign-in and richer model metadata. Older versions may still work with local models.
## Install the extension
@@ -38,6 +40,12 @@ ollama signin
Local models do not require sign-in.
## Context length
VS Code may show a model's maximum supported context length even when Ollama allocates a smaller context at runtime.
For local models, open Ollama **Settings**, set the context length to at least 64k, reload the VS Code window, and resend your prompt. See [Context length](/context-length) for more information.
## Troubleshooting
If Ollama models do not appear in the model picker:
+1 -1
View File
@@ -116,7 +116,7 @@ sudo systemctl status ollama
While AMD has contributed the `amdgpu` driver upstream to the official linux
kernel source, the version is older and may not support all ROCm features. We
recommend you install the latest driver from
https://www.amd.com/en/support/linux-drivers for best support of your Radeon
https://www.amd.com/en/support/download/linux-drivers.html for best support of your Radeon
GPU.
</Note>
+3 -7
View File
@@ -107,12 +107,8 @@ FROM <model name>:<tag>
FROM llama3.2
```
<Card title="Base Models" href="https://github.com/ollama/ollama#model-library">
A list of available base models
</Card>
<Card title="Base Models" href="https://ollama.com/library">
Additional models can be found at
<Card title="Model library" href="https://ollama.com/library">
Browse available models
</Card>
#### Build from a Safetensors model
@@ -152,7 +148,7 @@ PARAMETER <parameter> <parametervalue>
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | -------------------- |
| num_ctx | Sets the size of the context window used to generate the next token. (Default: 2048) | int | num_ctx 4096 |
| repeat_last_n | Sets how far back for the model to look back to prevent repetition. (Default: 64, 0 = disabled, -1 = num_ctx) | int | repeat_last_n 64 |
| repeat_penalty | Sets how strongly to penalize repetitions. A higher value (e.g., 1.5) will penalize repetitions more strongly, while a lower value (e.g., 0.9) will be more lenient. (Default: 1.1) | float | repeat_penalty 1.1 |
| repeat_penalty | Sets how strongly to penalize repetitions. A higher value (e.g., 1.5) will penalize repetitions more strongly, while a lower value (e.g., 0.9) will be more lenient. (Default: 1.0, disabled) | float | repeat_penalty 1.1 |
| temperature | The temperature of the model. Increasing the temperature will make the model answer more creatively. (Default: 0.8) | float | temperature 0.7 |
| seed | Sets the random number seed to use for generation. Setting this to a specific number will make the model generate the same text for the same prompt. (Default: 0) | int | seed 42 |
| stop | Sets the stop sequences to use. When this pattern is encountered the LLM will stop generating text and return. Multiple stop patterns may be set by specifying multiple separate `stop` parameters in a modelfile. | string | stop "AI assistant:" |
+6
View File
@@ -483,6 +483,12 @@ components:
template:
type: string
description: Prompt template to use for the model
renderer:
type: string
description: Name of the renderer for the model
parser:
type: string
description: Name of the parser for the model
license:
oneOf:
- type: string
+1 -1
View File
@@ -137,7 +137,7 @@ After upgrading, reboot and restart Ollama.
If you experience gibberish responses when models load across multiple AMD GPUs on Linux, see the following guide.
- https://rocm.docs.amd.com/projects/radeon/en/latest/docs/install/native_linux/mgpu.html#mgpu-known-issues-and-limitations
- https://rocm.docs.amd.com/projects/radeon-ryzen/en/latest/docs/install/installrad/native_linux/mgpu.html#mgpu-known-issues-and-limitations
## Windows Terminal Errors
+1 -1
View File
@@ -23,6 +23,7 @@ require (
github.com/agnivade/levenshtein v1.1.1
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/x/ansi v0.10.1
github.com/d4l3k/go-bfloat16 v0.0.0-20211005043715-690c3bdd05f1
github.com/dlclark/regexp2 v1.11.5
github.com/emirpasic/gods/v2 v2.0.0-alpha
@@ -48,7 +49,6 @@ require (
github.com/buger/jsonparser v1.1.1 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/chewxy/hm v1.0.0 // indirect
+16 -3
View File
@@ -2,8 +2,21 @@
This directory contains integration tests to exercise Ollama end-to-end to verify behavior
By default, these tests are disabled so `go test ./...` will exercise only unit tests. To run integration tests you must pass the integration tag. `go test -tags=integration ./...` Some tests require additional tags to enable to allow scoped testing to keep the duration reasonable. For example, testing a broad set of models requires `-tags=integration,models` and a longer timeout (~60m or more depending on the speed of your GPU.). To view the current set of tag combinations use `find integration -type f | xargs grep "go:build"`
By default, these tests are disabled so `go test ./...` will exercise only unit tests. To run integration tests, pass the `integration` tag and one of the scoped tags:
```bash
go test -tags=integration,fast -v -count 1 ./integration/
go test -tags=integration,release -v -count 1 -timeout 30m ./integration/
go test -tags=integration,library -v -count 1 -timeout 120m ./integration/
```
Tags:
- `fast`: quick runner/model smoke coverage.
- `release`: release regression coverage.
- `library`: broad library coverage requiring about 2.5 TiB of disk space.
Scope wiring and model selections live in `integration/reg_fast_test.go`, `integration/reg_release_test.go`, and `integration/reg_library_test.go`.
The integration tests have 2 modes of operating.
@@ -21,12 +34,12 @@ harness starts the server.
## Testing a New Model
When implementing new model architecture, use `OLLAMA_TEST_MODEL` to run the
integration suite against your model.
integration suite against your model with either the `fast` or `release` coverage.
```bash
# Build the binary first
go build .
# Run integration tests against it
OLLAMA_TEST_MODEL=mymodel go test -tags integration -v -count 1 -timeout 15m ./integration/
OLLAMA_TEST_MODEL=mymodel go test -tags=integration,fast -v -count 1 ./integration/
```
+48 -29
View File
@@ -14,6 +14,13 @@ import (
"github.com/ollama/ollama/api"
)
const (
apiTestTimeout = 4 * time.Minute
apiInitialResponseTimeout = time.Minute
apiOverrideInitialResponseTimeout = 2 * time.Minute
apiStreamResponseTimeout = 30 * time.Second
)
func assertBytesMatchToken(t *testing.T, label, token string, ints []int) {
t.Helper()
@@ -31,10 +38,12 @@ func assertBytesMatchToken(t *testing.T, label, token string, ints []int) {
}
}
func TestAPIGenerate(t *testing.T) {
initialTimeout := 60 * time.Second
streamTimeout := 30 * time.Second
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
func runAPIGenerate(t *testing.T) {
initialTimeout := apiInitialResponseTimeout
if testModel != "" {
initialTimeout = apiOverrideInitialResponseTimeout
}
ctx, cancel := context.WithTimeout(context.Background(), apiTestTimeout)
defer cancel()
// Set up the test data
req := api.GenerateRequest{
@@ -45,7 +54,6 @@ func TestAPIGenerate(t *testing.T) {
"seed": 123,
},
}
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
pullOrSkip(ctx, t, client, req.Model)
@@ -105,7 +113,7 @@ func TestAPIGenerate(t *testing.T) {
} // else incremental response, nothing to check right now...
buf.Write([]byte(response.Response))
if !stallTimer.Reset(streamTimeout) {
if !stallTimer.Reset(apiStreamResponseTimeout) {
return fmt.Errorf("stall was detected while streaming response, aborting")
}
return nil
@@ -188,10 +196,12 @@ func TestAPIGenerate(t *testing.T) {
}
}
func TestAPIChat(t *testing.T) {
initialTimeout := 60 * time.Second
streamTimeout := 30 * time.Second
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
func runAPIChat(t *testing.T) {
initialTimeout := apiInitialResponseTimeout
if testModel != "" {
initialTimeout = apiOverrideInitialResponseTimeout
}
ctx, cancel := context.WithTimeout(context.Background(), apiTestTimeout)
defer cancel()
// Set up the test data
req := api.ChatRequest{
@@ -207,7 +217,6 @@ func TestAPIChat(t *testing.T) {
"seed": 123,
},
}
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
pullOrSkip(ctx, t, client, req.Model)
@@ -265,7 +274,7 @@ func TestAPIChat(t *testing.T) {
}
} // else incremental response, nothing to check right now...
buf.Write([]byte(response.Message.Content))
if !stallTimer.Reset(streamTimeout) {
if !stallTimer.Reset(apiStreamResponseTimeout) {
return fmt.Errorf("stall was detected while streaming response, aborting")
}
return nil
@@ -310,11 +319,11 @@ func TestAPIChat(t *testing.T) {
}
}
func TestAPIListModels(t *testing.T) {
func runAPIListModels(t *testing.T) {
if testModel != "" {
t.Skip("skipping metadata test with model override")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), apiTestTimeout)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
@@ -331,44 +340,54 @@ func TestAPIListModels(t *testing.T) {
if len(resp.Models) == 0 {
t.Fatalf("list should not be empty")
}
model := resp.Models[0]
var model *api.ListModelResponse
for i := range resp.Models {
if resp.Models[i].Name == smol || resp.Models[i].Model == smol || strings.Contains(resp.Models[i].Name, smol) || strings.Contains(resp.Models[i].Model, smol) {
model = &resp.Models[i]
break
}
}
if model == nil {
t.Fatalf("list should include pulled model %s: %#v", smol, resp.Models)
}
if model.Name == "" {
t.Errorf("first model name empty: %#v", model)
t.Errorf("model name empty: %#v", model)
}
var nilTime time.Time
if model.ModifiedAt == nilTime {
t.Errorf("first model modified_at empty: %#v", model)
t.Errorf("model modified_at empty: %#v", model)
}
if model.Size == 0 {
t.Errorf("first model size empty: %#v", model)
t.Errorf("model size empty: %#v", model)
}
if model.Digest == "" {
t.Errorf("first model digest empty: %#v", model)
t.Errorf("model digest empty: %#v", model)
}
verifyModelDetails(t, model.Details)
}
func verifyModelDetails(t *testing.T, details api.ModelDetails) {
if details.Format == "" {
t.Errorf("first model details.format empty: %#v", details)
t.Errorf("model details.format empty: %#v", details)
}
if details.Family == "" {
t.Errorf("first model details.family empty: %#v", details)
t.Errorf("model details.family empty: %#v", details)
}
if details.ParameterSize == "" {
t.Errorf("first model details.parameter_size empty: %#v", details)
t.Errorf("model details.parameter_size empty: %#v", details)
}
if details.QuantizationLevel == "" {
t.Errorf("first model details.quantization_level empty: %#v", details)
t.Errorf("model details.quantization_level empty: %#v", details)
}
}
func TestAPIShowModel(t *testing.T) {
func runAPIShowModel(t *testing.T) {
if testModel != "" {
t.Skip("skipping metadata test with model override")
}
modelName := "llama3.2"
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), apiTestTimeout)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
@@ -405,8 +424,8 @@ func TestAPIShowModel(t *testing.T) {
}
}
func TestAPIGenerateLogprobs(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
func runAPIGenerateLogprobs(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), apiTestTimeout)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
@@ -518,8 +537,8 @@ func TestAPIGenerateLogprobs(t *testing.T) {
}
}
func TestAPIChatLogprobs(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
func runAPIChatLogprobs(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), apiTestTimeout)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
+50 -61
View File
@@ -18,12 +18,6 @@ import (
"github.com/ollama/ollama/api"
)
var defaultAudioModels = []string{
"nemotron3:33b",
"gemma4:e2b",
"gemma4:e4b",
}
// decodeTestAudio returns the test audio clip ("Why is the sky blue?", 16kHz mono WAV).
func decodeTestAudio(t *testing.T) api.ImageData {
t.Helper()
@@ -37,61 +31,56 @@ func decodeTestAudio(t *testing.T) api.ImageData {
// setupAudioModel pulls the model, preloads it, and skips if it doesn't support audio.
func setupAudioModel(ctx context.Context, t *testing.T, client *api.Client, model string) {
t.Helper()
if testModel == "" {
pullOrSkip(ctx, t, client, model)
}
pullOrSkip(ctx, t, client, model)
skipIfModelTooLargeForVRAM(ctx, t, client, model)
requireCapability(ctx, t, client, model, "audio")
err := client.Generate(ctx, &api.GenerateRequest{Model: model}, func(response api.GenerateResponse) error { return nil })
if err != nil {
t.Fatalf("failed to load model %s: %s", model, err)
}
preloadGenerateModel(ctx, t, client, api.GenerateRequest{Model: model})
}
// TestAudioTranscription tests that the model can transcribe audio to text.
func TestAudioTranscription(t *testing.T) {
for _, model := range testModels(defaultAudioModels) {
t.Run(model, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
setupAudioModel(ctx, t, client, model)
audio := decodeTestAudio(t)
noThink := &api.ThinkValue{Value: false}
req := api.ChatRequest{
Model: model,
Think: noThink,
Messages: []api.Message{
{
Role: "system",
Content: "Transcribe the audio exactly as spoken. Output only the spoken words. Do not answer any question in the audio.",
},
{
Role: "user",
Content: "What exact words are spoken in this audio?",
Images: []api.ImageData{audio},
},
},
Stream: &stream,
Options: map[string]any{
"temperature": 0,
"seed": 123,
"num_predict": 50,
},
}
// The audio says "Why is the sky blue?" — expect key words in transcription.
DoChat(ctx, t, client, req, []string{"sky", "blue"}, 60*time.Second, 10*time.Second)
})
}
func registerAudioTranscriptionCases(models []string) {
registerModelIntegrationCases("audio-transcription", models, runAudioTranscriptionModel)
}
// TestAudioResponse tests that the model can respond to a spoken question.
func TestAudioResponse(t *testing.T) {
for _, model := range testModels(defaultAudioModels) {
func runAudioTranscriptionModel(t *testing.T, model string) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
setupAudioModel(ctx, t, client, model)
audio := decodeTestAudio(t)
noThink := &api.ThinkValue{Value: false}
req := api.ChatRequest{
Model: model,
Think: noThink,
Messages: []api.Message{
{
Role: "system",
Content: "Transcribe the audio exactly as spoken. Output only the spoken words. Do not answer any question in the audio.",
},
{
Role: "user",
Content: "What exact words are spoken in this audio?",
Images: []api.ImageData{audio},
},
},
Stream: &stream,
Options: map[string]any{
"temperature": 0,
"seed": 123,
"num_predict": 50,
},
}
// The audio says "Why is the sky blue?" - expect key words in transcription.
DoChat(ctx, t, client, req, []string{"sky", "blue"}, 60*time.Second, 10*time.Second)
}
// runAudioResponse tests that the model can respond to a spoken question.
func runAudioResponse(t *testing.T, models []string) {
for _, model := range testModels(models) {
t.Run(model, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
@@ -128,9 +117,9 @@ func TestAudioResponse(t *testing.T) {
}
}
// TestOpenAIAudioTranscription tests the /v1/audio/transcriptions endpoint.
func TestOpenAIAudioTranscription(t *testing.T) {
for _, model := range testModels(defaultAudioModels) {
// runOpenAIAudioTranscription tests the /v1/audio/transcriptions endpoint.
func runOpenAIAudioTranscription(t *testing.T, models []string) {
for _, model := range testModels(models) {
t.Run(model, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
@@ -182,9 +171,9 @@ func TestOpenAIAudioTranscription(t *testing.T) {
}
}
// TestOpenAIChatWithAudio tests /v1/chat/completions with input_audio content.
func TestOpenAIChatWithAudio(t *testing.T) {
for _, model := range testModels(defaultAudioModels) {
// runOpenAIChatWithAudio tests /v1/chat/completions with input_audio content.
func runOpenAIChatWithAudio(t *testing.T, models []string) {
for _, model := range testModels(models) {
t.Run(model, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
+17 -21
View File
@@ -13,8 +13,8 @@ import (
"github.com/ollama/ollama/api"
)
func TestBlueSky(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
func runBlueSky(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
defer cancel()
// Set up the test data
req := api.ChatRequest{
@@ -34,17 +34,17 @@ func TestBlueSky(t *testing.T) {
ChatTestHelper(ctx, t, req, blueSkyExpected)
}
func TestUnicode(t *testing.T) {
func runUnicode(t *testing.T, model string) {
if testModel != "" {
t.Skip("uses hardcoded model, not applicable with model override")
}
skipUnderMinVRAM(t, 12) // Actual model load is ~26G
skipRegisteredMinVRAM(t, model)
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
defer cancel()
// Set up the test data
req := api.ChatRequest{
// DeepSeek has a Unicode tokenizer regex, making it a unicode torture test
Model: "deepseek-coder-v2:16b-lite-instruct-q2_K", // TODO is there an ollama-engine model we can switch to and keep the coverage?
Model: model, // TODO is there an ollama-engine model we can switch to and keep the coverage?
Messages: []api.Message{
{
Role: "user",
@@ -63,11 +63,7 @@ func TestUnicode(t *testing.T) {
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
pullOrSkip(ctx, t, client, req.Model)
slog.Info("loading", "model", req.Model)
err := client.Generate(ctx, &api.GenerateRequest{Model: req.Model}, func(response api.GenerateResponse) error { return nil })
if err != nil {
t.Fatalf("failed to load model %s: %s", req.Model, err)
}
preloadGenerateModel(ctx, t, client, api.GenerateRequest{Model: req.Model})
defer func() {
// best effort unload once we're done with the model
client.Generate(ctx, &api.GenerateRequest{Model: req.Model, KeepAlive: &api.Duration{Duration: 0}}, func(rsp api.GenerateResponse) error { return nil })
@@ -81,15 +77,15 @@ func TestUnicode(t *testing.T) {
}, 180*time.Second, 30*time.Second)
}
func TestExtendedUnicodeOutput(t *testing.T) {
func runExtendedUnicodeOutput(t *testing.T, model string) {
if testModel != "" {
t.Skip("uses hardcoded model, not applicable with model override")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
defer cancel()
// Set up the test data
req := api.ChatRequest{
Model: "gemma2:2b",
Model: model,
Messages: []api.Message{
{
Role: "user",
@@ -108,14 +104,14 @@ func TestExtendedUnicodeOutput(t *testing.T) {
DoChat(ctx, t, client, req, []string{"😀", "😊", "😁", "😂", "😄", "😃"}, 120*time.Second, 120*time.Second)
}
func TestUnicodeModelDir(t *testing.T) {
func runUnicodeModelDir(t *testing.T) {
// This is only useful for Windows with utf-16 characters, so skip this test for other platforms
if runtime.GOOS != "windows" {
t.Skip("Unicode test only applicable to windows")
}
// Only works for local testing
if os.Getenv("OLLAMA_TEST_EXISTING") != "" {
t.Skip("TestUnicodeModelDir only works for local testing, skipping")
t.Skip("runUnicodeModelDir only works for local testing, skipping")
}
modelDir, err := os.MkdirTemp("", "ollama_埃")
@@ -127,7 +123,7 @@ func TestUnicodeModelDir(t *testing.T) {
t.Setenv("OLLAMA_MODELS", modelDir)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
defer cancel()
req := api.ChatRequest{
@@ -147,22 +143,22 @@ func TestUnicodeModelDir(t *testing.T) {
ChatTestHelper(ctx, t, req, blueSkyExpected)
}
// TestNumPredict verifies that when num_predict is set, the model generates
// runNumPredict verifies that when num_predict is set, the model generates
// exactly that many tokens. It uses logprobs to count the actual tokens output.
func TestNumPredict(t *testing.T) {
func runNumPredict(t *testing.T, model string) {
if testModel != "" {
t.Skip("uses hardcoded model, not applicable with model override")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
pullOrSkip(ctx, t, client, "qwen3:0.6b")
pullOrSkip(ctx, t, client, model)
req := api.GenerateRequest{
Model: "qwen3:0.6b",
Model: model,
Prompt: "Write a long story.",
Stream: &stream,
Logprobs: true,
+144
View File
@@ -0,0 +1,144 @@
//go:build integration
package integration
import (
"context"
"fmt"
"log/slog"
"os"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/format"
)
var sweepVRAMWarning sync.Once
func registerChatCases(models []string) {
registerModelIntegrationCases("chat", models, runChatModel)
}
func runChatModel(t *testing.T, model string) {
t.Helper()
softTimeout, hardTimeout := getTimeouts(t)
slog.Info("Setting timeouts", "soft", softTimeout, "hard", hardTimeout)
ctx, cancel := context.WithTimeout(context.Background(), hardTimeout)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
if time.Since(started) > softTimeout {
t.Skip("skipping remaining tests to avoid excessive runtime")
}
skipRegisteredMinVRAM(t, model)
requireCapability(ctx, t, client, model, "completion")
skipIfTargetArchitecture(ctx, t, client, model)
skipIfModelTooLargeForSweepVRAM(ctx, t, client, model)
initialTimeout := 120 * time.Second
streamTimeout := 30 * time.Second
preloadGenerateModel(ctx, t, client, api.GenerateRequest{Model: model, KeepAlive: &api.Duration{Duration: 10 * time.Second}})
defer func() {
client.Generate(ctx, &api.GenerateRequest{Model: model, KeepAlive: &api.Duration{Duration: 0}}, func(rsp api.GenerateResponse) error { return nil })
}()
gpuPercent := getGPUPercent(ctx, t, client, model)
if gpuPercent < 80 {
slog.Warn("Low GPU percentage - increasing timeouts", "percent", gpuPercent)
initialTimeout = 240 * time.Second
streamTimeout = 40 * time.Second
}
req, anyResp := chatModelRequest(model)
DoChat(ctx, t, client, req, anyResp, initialTimeout, streamTimeout)
}
func chatModelRequest(model string) (api.ChatRequest, []string) {
req := api.ChatRequest{
Model: model,
Messages: []api.Message{
{
Role: "user",
Content: blueSkyPrompt,
},
},
KeepAlive: &api.Duration{Duration: 10 * time.Second},
Options: map[string]any{
"temperature": 0.1,
"seed": 123,
},
}
anyResp := blueSkyExpected
// Special cases
if model == "duckdb-nsql" {
anyResp = []string{"select", "from"}
} else if model == "granite3-guardian" || model == "shieldgemma" || model == "llama-guard3" || model == "bespoke-minicheck" {
anyResp = []string{"yes", "no", "safe", "unsafe"}
} else if model == "openthinker" {
anyResp = []string{"plugin", "im_sep", "components", "function call"}
} else if model == "starcoder" || model == "starcoder2" || model == "magicoder" || model == "deepseek-coder" {
req.Messages[0].Content = "def fibonacci():"
anyResp = []string{"f(n)", "sequence", "n-1", "main()", "__main__", "while"}
}
return req, anyResp
}
func skipIfTargetArchitecture(ctx context.Context, t *testing.T, client *api.Client, model string) {
t.Helper()
targetArch := os.Getenv("OLLAMA_TEST_ARCHITECTURE")
if targetArch == "" {
return
}
resp, err := client.Show(ctx, &api.ShowRequest{Name: model})
if err != nil {
t.Fatalf("unable to show model: %s", err)
}
arch := resp.ModelInfo["general.architecture"].(string)
if arch != targetArch {
t.Skip(fmt.Sprintf("Skipping %s architecture %s != %s", model, arch, targetArch))
}
}
func skipIfModelTooLargeForSweepVRAM(ctx context.Context, t *testing.T, client *api.Client, model string) {
t.Helper()
s := os.Getenv("OLLAMA_MAX_VRAM")
if s == "" {
sweepVRAMWarning.Do(func() {
slog.Warn("No VRAM info available, testing all models, so larger ones might timeout...")
})
return
}
maxVram, err := strconv.ParseUint(s, 10, 64)
if err != nil {
t.Fatalf("invalid OLLAMA_MAX_VRAM %v", err)
}
resp, err := client.List(ctx)
if err != nil {
t.Fatalf("list models failed %v", err)
}
for _, m := range resp.Models {
if modelNameMatches(model, m.Name) && float32(m.Size)*1.2 > float32(maxVram) {
t.Skipf("model %s is too large for available VRAM: %s > %s", model, format.HumanBytes(m.Size), format.HumanBytes(int64(maxVram)))
}
}
}
func modelNameMatches(model, name string) bool {
if name == model {
return true
}
return !strings.Contains(model, ":") && strings.HasPrefix(name, model+":")
}
+6 -17
View File
@@ -20,7 +20,7 @@ import (
)
// Send multiple requests in parallel (concurrently) to a single model and ensure responses are expected
func TestConcurrentChat(t *testing.T) {
func runConcurrentChat(t *testing.T) {
// Assumes all requests have the same model
req, resp := ChatRequests()
numParallel := int(envconfig.NumParallel() + 1)
@@ -31,16 +31,10 @@ func TestConcurrentChat(t *testing.T) {
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
pullOrSkip(ctx, t, client, req[0].Model)
// Get the server running (if applicable) warm the model up with a single initial request
slog.Info("loading", "model", req[0].Model)
err := client.Generate(ctx,
&api.GenerateRequest{Model: req[0].Model, KeepAlive: &api.Duration{Duration: 10 * time.Second}},
func(response api.GenerateResponse) error { return nil },
)
if err != nil {
t.Fatalf("failed to load model %s: %s", req[0].Model, err)
}
preloadGenerateModel(ctx, t, client, api.GenerateRequest{Model: req[0].Model, KeepAlive: &api.Duration{Duration: 10 * time.Second}})
var wg sync.WaitGroup
r := rand.New(rand.NewSource(0))
@@ -66,7 +60,7 @@ func TestConcurrentChat(t *testing.T) {
// Stress the scheduler and attempt to load more models than will fit to cause thrashing
// This test will always load at least 2 models even on CPU based systems
func TestMultiModelStress(t *testing.T) {
func runMultiModelStress(t *testing.T) {
if testModel != "" {
t.Skip("uses hardcoded models, not applicable with model override")
}
@@ -85,7 +79,6 @@ func TestMultiModelStress(t *testing.T) {
"llama3.2:1b",
"qwen3:0.6b",
"gemma2:2b",
"deepseek-r1:1.5b", // qwen2 arch
"gemma3:270m",
}
mediumModels := []string{
@@ -126,12 +119,8 @@ func TestMultiModelStress(t *testing.T) {
slog.Info("Loading models to find how many can fit in VRAM before overflowing")
chooseModels:
for i, model := range chosenModels {
req := &api.GenerateRequest{Model: model} // Leave KeepAlive unset so they stay loaded until the scheduler decides to unload them
slog.Info("loading", "model", model)
err = client.Generate(ctx, req, func(response api.GenerateResponse) error { return nil })
if err != nil {
t.Fatalf("failed to load model %s: %s", model, err)
}
// Leave KeepAlive unset so they stay loaded until the scheduler decides to unload them.
preloadGenerateModel(ctx, t, client, api.GenerateRequest{Model: model})
targetLoadCount++
if i > 0 {
models, err := client.ListRunning(ctx)
+36 -45
View File
@@ -14,7 +14,12 @@ import (
"github.com/ollama/ollama/api"
)
func TestLongInputContext(t *testing.T) {
const (
longInputTimeout = 2 * time.Minute
longInputModelOverrideTimeout = 3 * time.Minute
)
func runLongInputContext(t *testing.T) {
// Setting NUM_PARALLEL to 1 ensures the allocated context is exactly what
// we asked for and there is nothing extra that we could spill over into.
// Context shift happens after a prompt has been admitted to a slot. Initial
@@ -23,7 +28,11 @@ func TestLongInputContext(t *testing.T) {
// prompt while llama-server reports it as too large to admit.
t.Setenv("OLLAMA_NUM_PARALLEL", "1")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
timeout := longInputTimeout
if testModel != "" {
timeout = longInputModelOverrideTimeout
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
req := api.ChatRequest{
Model: smol,
@@ -79,7 +88,7 @@ func isContextLimitError(err string) bool {
strings.Contains(err, "too long"))
}
func TestContextExhaustion(t *testing.T) {
func runContextExhaustion(t *testing.T) {
// Setting NUM_PARALLEL to 1 ensures the allocated context is exactly what
// we asked for and there is nothing extra that we could spill over into
t.Setenv("OLLAMA_NUM_PARALLEL", "1")
@@ -128,11 +137,13 @@ func containsEmoji(s string) bool {
}
// Send multiple generate requests with prior context and ensure the response is coherant and expected
func TestParallelGenerateWithHistory(t *testing.T) {
func runParallelGenerateWithHistory(t *testing.T, modelName string) {
if testModel != "" {
t.Skip("uses hardcoded model, not applicable with model override")
// The Generate API's Context field (token array continuation) is not
// supported by all runners (e.g. MLX). Chat history works; this is
// the only generate-specific continuation path.
t.Skip("generate context continuation not supported by all runners")
}
modelName := "gpt-oss:20b"
req, resp := GenerateRequests()
numParallel := 2
iterLimit := 2
@@ -144,16 +155,10 @@ func TestParallelGenerateWithHistory(t *testing.T) {
defer cleanup()
initialTimeout := 120 * time.Second
streamTimeout := 20 * time.Second
prepareParallelHistoryModel(ctx, t, client, modelName)
// Get the server running (if applicable) warm the model up with a single initial request
slog.Info("loading", "model", modelName)
err := client.Generate(ctx,
&api.GenerateRequest{Model: modelName, KeepAlive: &api.Duration{Duration: 10 * time.Second}},
func(response api.GenerateResponse) error { return nil },
)
if err != nil {
t.Fatalf("failed to load model %s: %s", modelName, err)
}
preloadGenerateModel(ctx, t, client, api.GenerateRequest{Model: modelName, KeepAlive: &api.Duration{Duration: 10 * time.Second}})
gpuPercent := getGPUPercent(ctx, t, client, modelName)
if gpuPercent < 80 && gpuPercent > 50 {
slog.Warn("Low GPU percentage - increasing timeouts", "percent", gpuPercent)
@@ -190,7 +195,7 @@ func TestParallelGenerateWithHistory(t *testing.T) {
}
// Send generate requests with prior context and ensure the response is coherant and expected
func TestGenerateWithHistory(t *testing.T) {
func runGenerateWithHistory(t *testing.T) {
if testModel != "" {
// The Generate API's Context field (token array continuation) is not
// supported by all runners (e.g. MLX). Chat history works; this is
@@ -212,16 +217,10 @@ func TestGenerateWithHistory(t *testing.T) {
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
pullOrSkip(ctx, t, client, req.Model)
// Get the server running (if applicable) warm the model up with a single initial request
slog.Info("loading", "model", req.Model)
err := client.Generate(ctx,
&api.GenerateRequest{Model: req.Model, KeepAlive: &api.Duration{Duration: 10 * time.Second}, Options: req.Options},
func(response api.GenerateResponse) error { return nil },
)
if err != nil {
t.Fatalf("failed to load model %s: %s", req.Model, err)
}
preloadGenerateModel(ctx, t, client, api.GenerateRequest{Model: req.Model, KeepAlive: &api.Duration{Duration: 10 * time.Second}, Options: req.Options})
req.Context = DoGenerate(ctx, t, client, req, rainbowExpected, 30*time.Second, 20*time.Second)
@@ -236,11 +235,7 @@ func TestGenerateWithHistory(t *testing.T) {
}
// Send multiple chat requests with prior context and ensure the response is coherant and expected
func TestParallelChatWithHistory(t *testing.T) {
if testModel != "" {
t.Skip("uses hardcoded model, not applicable with model override")
}
modelName := "gpt-oss:20b"
func runParallelChatWithHistory(t *testing.T, modelName string) {
req, resp := ChatRequests()
numParallel := 2
iterLimit := 2
@@ -252,16 +247,10 @@ func TestParallelChatWithHistory(t *testing.T) {
defer cleanup()
initialTimeout := 120 * time.Second
streamTimeout := 20 * time.Second
prepareParallelHistoryModel(ctx, t, client, modelName)
// Get the server running (if applicable) warm the model up with a single initial empty request
slog.Info("loading", "model", modelName)
err := client.Generate(ctx,
&api.GenerateRequest{Model: modelName, KeepAlive: &api.Duration{Duration: 10 * time.Second}},
func(response api.GenerateResponse) error { return nil },
)
if err != nil {
t.Fatalf("failed to load model %s: %s", modelName, err)
}
preloadGenerateModel(ctx, t, client, api.GenerateRequest{Model: modelName, KeepAlive: &api.Duration{Duration: 10 * time.Second}})
gpuPercent := getGPUPercent(ctx, t, client, modelName)
if gpuPercent < 80 && gpuPercent > 50 {
slog.Warn("Low GPU percentage - increasing timeouts", "percent", gpuPercent)
@@ -302,8 +291,16 @@ func TestParallelChatWithHistory(t *testing.T) {
wg.Wait()
}
func prepareParallelHistoryModel(ctx context.Context, t *testing.T, client *api.Client, modelName string) {
t.Helper()
skipRegisteredMinVRAM(t, modelName)
requireCapability(ctx, t, client, modelName, "completion")
skipIfTargetArchitecture(ctx, t, client, modelName)
skipIfModelTooLargeForSweepVRAM(ctx, t, client, modelName)
}
// Send generate requests with prior context and ensure the response is coherant and expected
func TestChatWithHistory(t *testing.T) {
func runChatWithHistory(t *testing.T) {
req := api.ChatRequest{
Model: smol,
Stream: &stream,
@@ -324,16 +321,10 @@ func TestChatWithHistory(t *testing.T) {
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
pullOrSkip(ctx, t, client, req.Model)
// Get the server running (if applicable) warm the model up with a single initial request
slog.Info("loading", "model", req.Model)
err := client.Generate(ctx,
&api.GenerateRequest{Model: req.Model, KeepAlive: &api.Duration{Duration: 10 * time.Second}, Options: req.Options},
func(response api.GenerateResponse) error { return nil },
)
if err != nil {
t.Fatalf("failed to load model %s: %s", req.Model, err)
}
preloadGenerateModel(ctx, t, client, api.GenerateRequest{Model: req.Model, KeepAlive: &api.Duration{Duration: 10 * time.Second}, Options: req.Options})
assistant := DoChat(ctx, t, client, req, rainbowExpected, 30*time.Second, 20*time.Second)
-107
View File
@@ -1,107 +0,0 @@
//go:build integration && imagegen
package integration
import (
"context"
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/ollama/ollama/api"
)
func TestCreateImageGen(t *testing.T) {
skipIfRemote(t)
skipUnderMinVRAM(t, 13)
// Allow overriding the model directory via env var for local testing,
// since the model is ~33GB and may already be downloaded elsewhere.
modelDir := os.Getenv("OLLAMA_TEST_IMAGEGEN_MODEL_DIR")
if modelDir == "" {
modelDir = filepath.Join(testdataModelsDir, "Z-Image-Turbo")
downloadHFModel(t, "Tongyi-MAI/Z-Image-Turbo", modelDir)
} else {
t.Logf("Using existing imagegen model at %s", modelDir)
}
// Verify it looks like a valid imagegen model directory
if _, err := os.Stat(filepath.Join(modelDir, "model_index.json")); err != nil {
t.Fatalf("model_index.json not found in %s — not a valid imagegen model directory", modelDir)
}
ensureMLXLibraryPath(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
modelName := "test-z-image-turbo-create"
absModelDir, err := filepath.Abs(modelDir)
if err != nil {
t.Fatalf("Failed to get absolute path: %v", err)
}
// Create a Modelfile pointing to the diffusers model directory
tmpModelfile := filepath.Join(t.TempDir(), "Modelfile")
if err := os.WriteFile(tmpModelfile, []byte("FROM "+absModelDir+"\n"), 0o644); err != nil {
t.Fatalf("Failed to write Modelfile: %v", err)
}
t.Logf("Creating imagegen model from %s", absModelDir)
runOllamaCreate(ctx, t, modelName, "--experimental", "-f", tmpModelfile)
// Verify model exists via show
showReq := &api.ShowRequest{Name: modelName}
showResp, err := client.Show(ctx, showReq)
if err != nil {
t.Fatalf("Model show failed after create: %v", err)
}
t.Logf("Created model details: %+v", showResp.Details)
// Generate an image to verify the model isn't corrupted
t.Log("Generating test image...")
imageBase64, err := generateImage(ctx, client, modelName, "A red circle on a white background")
if err != nil {
if strings.Contains(err.Error(), "image generation not available") {
t.Skip("Target system does not support image generation")
} else if strings.Contains(err.Error(), "insufficient memory for image generation") {
t.Skip("insufficient memory for image generation")
} else if strings.Contains(err.Error(), "ollama-mlx: no such file or directory") {
t.Skip("unsupported architecture")
}
t.Fatalf("Image generation failed: %v", err)
}
// Verify we got valid image data
imageData, err := base64.StdEncoding.DecodeString(imageBase64)
if err != nil {
t.Fatalf("Failed to decode base64 image: %v", err)
}
t.Logf("Generated image: %d bytes", len(imageData))
if len(imageData) < 1000 {
t.Fatalf("Generated image suspiciously small (%d bytes), likely corrupted", len(imageData))
}
// Check for PNG or JPEG magic bytes
isPNG := len(imageData) >= 4 && imageData[0] == 0x89 && imageData[1] == 'P' && imageData[2] == 'N' && imageData[3] == 'G'
isJPEG := len(imageData) >= 2 && imageData[0] == 0xFF && imageData[1] == 0xD8
if !isPNG && !isJPEG {
t.Fatalf("Generated image is neither PNG nor JPEG (first bytes: %x)", imageData[:min(8, len(imageData))])
}
t.Logf("Image format validated (PNG=%v, JPEG=%v)", isPNG, isJPEG)
// Cleanup: delete the model
deleteReq := &api.DeleteRequest{Model: modelName}
if err := client.Delete(ctx, deleteReq); err != nil {
t.Logf("Warning: failed to delete test model: %v", err)
}
}
+4 -4
View File
@@ -19,7 +19,7 @@ import (
const testdataModelsDir = "testdata/models"
// skipIfRemote skips the test if OLLAMA_HOST points to a non-local server.
// Safetensors/imagegen creation requires localhost since it reads model files
// Safetensors creation requires localhost since it reads model files.
// from disk and uses the --experimental CLI path.
func skipIfRemote(t *testing.T) {
t.Helper()
@@ -43,7 +43,7 @@ func skipIfRemote(t *testing.T) {
if ip != nil && (ip.IsLoopback() || ip.IsUnspecified()) {
return
}
t.Skipf("safetensors/imagegen creation requires a local server (OLLAMA_HOST=%s)", host)
t.Skipf("safetensors creation requires a local server (OLLAMA_HOST=%s)", host)
}
// findHFCLI returns the path to the HuggingFace CLI, or "" if not found.
@@ -136,7 +136,7 @@ func runOllamaCreate(ctx context.Context, t *testing.T, args ...string) {
}
}
func TestCreateSafetensorsLLM(t *testing.T) {
func runCreateSafetensorsLLM(t *testing.T) {
if testModel != "" {
t.Skip("exercises create pipeline with a fixed source model, not applicable with model override")
}
@@ -214,7 +214,7 @@ func TestCreateSafetensorsLLM(t *testing.T) {
}
}
func TestCreateGGUF(t *testing.T) {
func runCreateGGUF(t *testing.T) {
if testModel != "" {
t.Skip("exercises create pipeline with a fixed source model, not applicable with model override")
}
+204
View File
@@ -0,0 +1,204 @@
//go:build integration
package integration
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/ollama/ollama/api"
)
func registerEmbeddingCases(models []string) {
registerEmbeddingCasesWithFallback(models, false)
}
func registerLibraryEmbeddingCases(models []string) {
registerEmbeddingCasesWithFallback(models, true)
}
func registerEmbeddingCasesWithFallback(models []string, smokeMissing bool) {
testCases, err := loadEmbeddingTestCases()
if err != nil {
registerIntegrationCases(integrationCase{
Key: "embed/testdata",
Case: "embed",
Model: "testdata",
Run: func(t *testing.T) {
t.Fatalf("failed to load embedding test data: %s", err)
},
})
return
}
if testModel != "" {
models = []string{testModel}
}
cases := make([]integrationCase, 0, len(models))
for _, model := range models {
model := model
expected, ok := embeddingExpected(testCases, model)
if !ok {
if smokeMissing || testModel != "" {
cases = append(cases, embeddingSmokeCase(model))
continue
}
cases = append(cases, integrationCase{
Key: "embed/" + model,
Case: "embed",
Model: model,
Run: func(t *testing.T) {
t.Skipf("no embedding expectation for model %s", model)
},
})
continue
}
cases = append(cases, embeddingCase(model, expected))
}
registerIntegrationCases(cases...)
}
func embeddingSmokeCase(model string) integrationCase {
return integrationCase{
Key: "embed/" + model,
Case: "embed",
Model: model,
Run: func(t *testing.T) {
runEmbeddingSmokeModel(t, model)
},
}
}
func embeddingCase(model string, expected []float64) integrationCase {
return integrationCase{
Key: "embed/" + model,
Case: "embed",
Model: model,
Run: func(t *testing.T) {
runEmbeddingModel(t, model, expected)
},
}
}
func loadEmbeddingTestCases() (map[string][]float64, error) {
data, err := os.ReadFile(filepath.Join("testdata", "embed.json"))
if err != nil {
return nil, err
}
testCases := map[string][]float64{}
if err := json.Unmarshal(data, &testCases); err != nil {
return nil, err
}
return testCases, nil
}
func embeddingExpected(testCases map[string][]float64, model string) ([]float64, bool) {
if expected, ok := testCases[model]; ok {
return expected, true
}
if !strings.Contains(model, ":") {
expected, ok := testCases[model+":latest"]
return expected, ok
}
return nil, false
}
func runEmbeddingModel(t *testing.T, model string, expected []float64) {
t.Helper()
softTimeout, hardTimeout := getTimeouts(t)
ctx, cancel := context.WithTimeout(context.Background(), hardTimeout)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
if time.Since(started) > softTimeout {
t.Skip("skipping remaining tests to avoid excessive runtime")
}
pullOrSkip(ctx, t, client, model)
skipIfModelTooLargeForSweepVRAM(ctx, t, client, model)
req := api.EmbeddingRequest{
Model: model,
Prompt: "why is the sky blue?",
KeepAlive: &api.Duration{Duration: 10 * time.Second},
Options: map[string]any{
"temperature": 0,
"seed": 123,
},
}
resp, err := client.Embeddings(ctx, &req)
if err != nil {
t.Fatalf("embeddings call failed %s", err)
}
defer func() {
client.Generate(ctx, &api.GenerateRequest{Model: req.Model, KeepAlive: &api.Duration{Duration: 0}}, func(rsp api.GenerateResponse) error { return nil })
}()
if len(resp.Embedding) == 0 {
t.Errorf("zero length embedding response")
}
if len(expected) != len(resp.Embedding) {
expStr := make([]string, len(resp.Embedding))
for i, v := range resp.Embedding {
expStr[i] = fmt.Sprintf("%0.6f", v)
}
// When adding new models, use this output to populate the testdata/embed.json
fmt.Printf("expected\n%s\n", strings.Join(expStr, ", "))
t.Fatalf("expected %d, got %d", len(expected), len(resp.Embedding))
}
sim := cosineSimilarity(resp.Embedding, expected)
if sim < 0.99 {
t.Fatalf("expected %v, got %v (similarity: %f)", expected[0:5], resp.Embedding[0:5], sim)
}
}
func runEmbeddingSmokeModel(t *testing.T, model string) {
t.Helper()
softTimeout, hardTimeout := getTimeouts(t)
ctx, cancel := context.WithTimeout(context.Background(), hardTimeout)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
if time.Since(started) > softTimeout {
t.Skip("skipping remaining tests to avoid excessive runtime")
}
requireCapability(ctx, t, client, model, "embedding")
skipIfModelTooLargeForSweepVRAM(ctx, t, client, model)
req := api.EmbedRequest{
Model: model,
Input: []string{"cat", "kitten", "dog"},
KeepAlive: &api.Duration{Duration: 10 * time.Second},
}
resp, err := embedTestHelper(ctx, client, t, req)
if err != nil {
t.Fatal(err)
}
defer func() {
client.Generate(ctx, &api.GenerateRequest{Model: req.Model, KeepAlive: &api.Duration{Duration: 0}}, func(rsp api.GenerateResponse) error { return nil })
}()
if len(resp.Embeddings) != 3 {
t.Fatalf("expected 3 embeddings, got %d", len(resp.Embeddings))
}
for i, embedding := range resp.Embeddings {
if len(embedding) == 0 {
t.Fatalf("embedding %d was empty", i)
}
}
cosRelated := cosineSimilarity(resp.Embeddings[0], resp.Embeddings[1])
cosUnrelated := cosineSimilarity(resp.Embeddings[0], resp.Embeddings[2])
if cosRelated <= cosUnrelated {
t.Fatalf("expected related terms to be closer than unrelated terms: cat/kitten=%f cat/dog=%f", cosRelated, cosUnrelated)
}
}
+37 -24
View File
@@ -10,7 +10,6 @@ import (
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/ollama/ollama/api"
)
@@ -61,6 +60,19 @@ func requireEmbedErrorContainsAny(t *testing.T, err error, substrings ...string)
t.Fatalf("expected error containing one of %q, got: %v", substrings, err)
}
func requireSimilarEmbedding(t *testing.T, want, got []float32) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("expected %d embedding floats, got %d", len(want), len(got))
}
sim := cosineSimilarity(got, want)
if sim < 0.999 {
t.Fatalf("expected embedding similar to %v, got %v (similarity: %f)", want[0:5], got[0:5], sim)
}
}
func euclideanDistance[V float32 | float64](v1, v2 []V) V {
if len(v1) != len(v2) {
return V(math.Inf(1))
@@ -88,13 +100,13 @@ func manhattanDistance[V float32 | float64](v1, v2 []V) V {
return sum
}
func TestEmbedCosineDistanceCorrelation(t *testing.T) {
func runEmbedCosineDistanceCorrelation(t *testing.T, models []string) {
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
for _, model := range testModels(libraryEmbedModels) {
for _, model := range testModels(models) {
t.Run(model, func(t *testing.T) {
if testModel != "" {
requireCapability(ctx, t, client, model, "embedding")
@@ -163,7 +175,7 @@ func TestEmbedCosineDistanceCorrelation(t *testing.T) {
}
}
func TestAllMiniLMEmbeddings(t *testing.T) {
func runAllMiniLMEmbeddings(t *testing.T) {
if testModel != "" {
t.Skip("uses hardcoded model, not applicable with model override")
}
@@ -196,7 +208,7 @@ func TestAllMiniLMEmbeddings(t *testing.T) {
}
}
func TestAllMiniLMEmbed(t *testing.T) {
func runAllMiniLMEmbed(t *testing.T) {
if testModel != "" {
t.Skip("uses hardcoded model, not applicable with model override")
}
@@ -236,7 +248,7 @@ func TestAllMiniLMEmbed(t *testing.T) {
}
}
func TestAllMiniLMBatchEmbed(t *testing.T) {
func runAllMiniLMBatchEmbed(t *testing.T) {
if testModel != "" {
t.Skip("uses hardcoded model, not applicable with model override")
}
@@ -286,7 +298,7 @@ func TestAllMiniLMBatchEmbed(t *testing.T) {
}
}
func TestAllMiniLMEmbedTruncate(t *testing.T) {
func runAllMiniLMEmbedTruncate(t *testing.T) {
if testModel != "" {
t.Skip("uses hardcoded model, not applicable with model override")
}
@@ -321,9 +333,7 @@ func TestAllMiniLMEmbedTruncate(t *testing.T) {
t.Fatal(err)
}
if diff := cmp.Diff(want.Embeddings[0], got.Embeddings[0]); diff != "" {
t.Errorf("embedding mismatch (-want +got):\n%s", diff)
}
requireSimilarEmbedding(t, want.Embeddings[0], got.Embeddings[0])
},
},
{
@@ -338,9 +348,7 @@ func TestAllMiniLMEmbedTruncate(t *testing.T) {
t.Fatal(err)
}
t.Logf("PromptEvalCount: want=%d got=%d", want.PromptEvalCount, got.PromptEvalCount)
if diff := cmp.Diff(want.Embeddings[0], got.Embeddings[0]); diff != "" {
t.Errorf("embedding mismatch (-want +got):\n%s", diff)
}
requireSimilarEmbedding(t, want.Embeddings[0], got.Embeddings[0])
},
},
{
@@ -356,9 +364,7 @@ func TestAllMiniLMEmbedTruncate(t *testing.T) {
t.Fatal(err)
}
t.Logf("PromptEvalCount: want=%d got=%d", want.PromptEvalCount, got.PromptEvalCount)
if diff := cmp.Diff(want.Embeddings[0], got.Embeddings[0]); diff != "" {
t.Errorf("embedding mismatch (-want +got):\n%s", diff)
}
requireSimilarEmbedding(t, want.Embeddings[0], got.Embeddings[0])
},
},
{
@@ -432,7 +438,7 @@ func embedTestHelper(ctx context.Context, client *api.Client, t *testing.T, req
return client.Embed(ctx, &req)
}
func TestEmbedTruncation(t *testing.T) {
func runEmbedTruncation(t *testing.T, models []string) {
// Use test deadline if set, otherwise default to 2 minutes
timeout := 2 * time.Minute
if deadline, ok := t.Deadline(); ok {
@@ -443,7 +449,7 @@ func TestEmbedTruncation(t *testing.T) {
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
for _, model := range testModels(libraryEmbedModels) {
for _, model := range testModels(models) {
model := model
t.Run(model, func(t *testing.T) {
if testModel != "" {
@@ -454,6 +460,9 @@ func TestEmbedTruncation(t *testing.T) {
t.Skip("skipping remaining tests to avoid timeout")
}
pullOrSkip(ctx, t, client, model)
skipIfModelTooLargeForSweepVRAM(ctx, t, client, model)
// Give each model its own budget to account for first-time pulls/loads
mctx, mcancel := context.WithTimeout(ctx, 3*time.Minute)
defer mcancel()
@@ -507,19 +516,22 @@ func TestEmbedTruncation(t *testing.T) {
}
}
// TestEmbedLargeInput tests that embedding models can handle large inputs that would exceed typical batch sizes.
func TestEmbedLargeInput(t *testing.T) {
// runEmbedLargeInput tests that embedding models can handle large inputs that would exceed typical batch sizes.
func runEmbedLargeInput(t *testing.T, models []string) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
for _, model := range testModels(libraryEmbedModels) {
for _, model := range testModels(models) {
model := model
t.Run(model, func(t *testing.T) {
if testModel != "" {
requireCapability(ctx, t, client, model, "embedding")
}
pullOrSkip(ctx, t, client, model)
skipIfModelTooLargeForSweepVRAM(ctx, t, client, model)
mctx, mcancel := context.WithTimeout(ctx, 2*time.Minute)
defer mcancel()
@@ -567,11 +579,11 @@ func TestEmbedLargeInput(t *testing.T) {
}
}
// TestEmbedStatusCode tests that errors from the embedding endpoint
// runEmbedStatusCode tests that errors from the embedding endpoint
// properly preserve their HTTP status codes when returned to the client.
// This test specifically checks the error handling path in EmbedHandler
// where api.StatusError errors should maintain their original status code.
func TestEmbedStatusCode(t *testing.T) {
func runEmbedStatusCode(t *testing.T, models []string) {
// Use test deadline if set, otherwise default to 2 minutes
timeout := 2 * time.Minute
if deadline, ok := t.Deadline(); ok {
@@ -582,7 +594,7 @@ func TestEmbedStatusCode(t *testing.T) {
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
for _, model := range testModels(libraryEmbedModels) {
for _, model := range testModels(models) {
model := model
t.Run(model, func(t *testing.T) {
if testModel != "" {
@@ -598,6 +610,7 @@ func TestEmbedStatusCode(t *testing.T) {
// Pull the model if needed
pullOrSkip(mctx, t, client, model)
skipIfModelTooLargeForSweepVRAM(mctx, t, client, model)
t.Run("truncation error status code", func(t *testing.T) {
truncFalse := false
-151
View File
@@ -1,151 +0,0 @@
//go:build integration
package integration
import (
"context"
"encoding/base64"
"fmt"
"strings"
"testing"
"time"
"github.com/ollama/ollama/api"
)
func TestImageGeneration(t *testing.T) {
if testModel != "" {
t.Skip("uses hardcoded models, not applicable with model override")
}
skipUnderMinVRAM(t, 32)
type testCase struct {
imageGenModel string
visionModel string
prompt string
expectedWords []string
}
testCases := []testCase{
{
imageGenModel: "jmorgan/z-image-turbo",
visionModel: "qwen2.5vl:3b",
prompt: "A cartoon style llama flying like a superhero through the air with clouds in the background",
expectedWords: []string{"llama", "flying", "cartoon", "cloud", "sky", "superhero", "air", "animal", "camelid"},
},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("%s->%s", tc.imageGenModel, tc.visionModel), func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
// Pull both models
pullOrSkip(ctx, t, client, tc.imageGenModel)
pullOrSkip(ctx, t, client, tc.visionModel)
// Generate the image
t.Logf("Generating image with prompt: %s", tc.prompt)
imageBase64, err := generateImage(ctx, client, tc.imageGenModel, tc.prompt)
if err != nil {
if strings.Contains(err.Error(), "image generation not available") {
t.Skip("Target system does not support image generation")
} else if strings.Contains(err.Error(), "executable file not found in") { // Windows pattern, not yet supported
t.Skip("Windows does not support image generation yet")
} else if strings.Contains(err.Error(), "CUDA driver version is insufficient") {
t.Skip("Driver is too old")
} else if strings.Contains(err.Error(), "insufficient memory for image generation") {
t.Skip("insufficient memory for image generation")
} else if strings.Contains(err.Error(), "error while loading shared libraries: libcuda.so.1") { // AMD GPU or CPU
t.Skip("CUDA GPU is not available")
} else if strings.Contains(err.Error(), "ollama-mlx: no such file or directory") {
// most likely linux arm - not supported yet
t.Skip("unsupported architecture")
} else if strings.Contains(err.Error(), "are available") {
t.Skip("insufficient VRAM for image generation model")
} else if strings.Contains(err.Error(), "failed to create server") {
t.Skip("image generation server failed to start")
}
t.Fatalf("failed to generate image: %v", err)
}
imageData, err := base64.StdEncoding.DecodeString(imageBase64)
if err != nil {
t.Fatalf("failed to decode image: %v", err)
}
t.Logf("Generated image: %d bytes", len(imageData))
// Preload vision model and check GPU loading
err = client.Generate(ctx, &api.GenerateRequest{Model: tc.visionModel}, func(response api.GenerateResponse) error { return nil })
if err != nil {
t.Fatalf("failed to load vision model: %v", err)
}
// Use vision model to describe the image
chatReq := api.ChatRequest{
Model: tc.visionModel,
Messages: []api.Message{
{
Role: "user",
Content: "Describe this image in detail. What is shown? What style is it? What is the main subject doing?",
Images: []api.ImageData{imageData},
},
},
Stream: &stream,
Options: map[string]any{
"seed": 42,
"temperature": 0.0,
},
}
// Verify the vision model's response contains expected keywords
response := DoChat(ctx, t, client, chatReq, tc.expectedWords, 240*time.Second, 30*time.Second)
if response != nil {
t.Logf("Vision model response: %s", response.Content)
// Additional detailed check for keywords
content := strings.ToLower(response.Content)
foundWords := []string{}
missingWords := []string{}
for _, word := range tc.expectedWords {
if strings.Contains(content, word) {
foundWords = append(foundWords, word)
} else {
missingWords = append(missingWords, word)
}
}
t.Logf("Found keywords: %v", foundWords)
if len(missingWords) > 0 {
t.Logf("Missing keywords (at least one was found so test passed): %v", missingWords)
}
}
})
}
}
// generateImage calls the Ollama API to generate an image and returns the base64 image data
func generateImage(ctx context.Context, client *api.Client, model, prompt string) (string, error) {
var imageBase64 string
err := client.Generate(ctx, &api.GenerateRequest{
Model: model,
Prompt: prompt,
}, func(resp api.GenerateResponse) error {
if resp.Image != "" {
imageBase64 = resp.Image
}
return nil
})
if err != nil {
return "", fmt.Errorf("failed to generate image: %w", err)
}
if imageBase64 == "" {
return "", fmt.Errorf("no image data in response")
}
return imageBase64, nil
}
-72
View File
@@ -1,72 +0,0 @@
//go:build integration && library
package integration
import (
"context"
"fmt"
"log/slog"
"os"
"testing"
"time"
"github.com/ollama/ollama/api"
)
// First run of this scenario on a target system will take a long time to download
// ~1.5TB of models. Set a sufficiently large -timeout for your network speed
func TestLibraryModelsChat(t *testing.T) {
softTimeout, hardTimeout := getTimeouts(t)
slog.Info("Setting timeouts", "soft", softTimeout, "hard", hardTimeout)
ctx, cancel := context.WithTimeout(context.Background(), hardTimeout)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
targetArch := os.Getenv("OLLAMA_TEST_ARCHITECTURE")
for _, model := range testModels(libraryChatModels) {
t.Run(model, func(t *testing.T) {
if time.Now().Sub(started) > softTimeout {
t.Skip("skipping remaining tests to avoid excessive runtime")
}
pullOrSkip(ctx, t, client, model)
if targetArch != "" {
resp, err := client.Show(ctx, &api.ShowRequest{Name: model})
if err != nil {
t.Fatalf("unable to show model: %s", err)
}
arch := resp.ModelInfo["general.architecture"].(string)
if arch != targetArch {
t.Skip(fmt.Sprintf("Skipping %s architecture %s != %s", model, arch, targetArch))
}
}
req := api.ChatRequest{
Model: model,
Messages: []api.Message{
{
Role: "user",
Content: blueSkyPrompt,
},
},
KeepAlive: &api.Duration{Duration: 10 * time.Second},
Options: map[string]interface{}{
"temperature": 0.1,
"seed": 123,
},
}
anyResp := blueSkyExpected
// Special cases
if model == "duckdb-nsql" {
anyResp = []string{"select", "from"}
} else if model == "granite3-guardian" || model == "shieldgemma" || model == "llama-guard3" || model == "bespoke-minicheck" {
anyResp = []string{"yes", "no", "safe", "unsafe"}
} else if model == "openthinker" {
anyResp = []string{"plugin", "im_sep", "components", "function call"}
} else if model == "starcoder" || model == "starcoder2" || model == "magicoder" || model == "deepseek-coder" {
req.Messages[0].Content = "def fibonacci():"
anyResp = []string{"f(n)", "sequence", "n-1", "main()", "__main__", "while"}
}
DoChat(ctx, t, client, req, anyResp, 120*time.Second, 30*time.Second)
})
}
}
+326 -558
View File
@@ -11,69 +11,53 @@ import (
"github.com/ollama/ollama/api"
)
func TestVisionModels(t *testing.T) {
skipUnderMinVRAM(t, 6)
defaultVisionModels := []string{
"gemma4",
"qwen2.5vl",
// "llama3.2-vision", // TODO: re-enable when llama.cpp supports mllama.
"gemma3",
"qwen3-vl:8b",
"qwen3-vl:30b",
"ministral-3",
}
skipIfNoVisionOverride(t)
for _, model := range testModels(defaultVisionModels) {
t.Run(model, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
requireCapability(ctx, t, client, model, "vision")
pullOrSkip(ctx, t, client, model)
image, err := base64.StdEncoding.DecodeString(imageEncoding)
if err != nil {
t.Fatal(err)
}
req := api.ChatRequest{
Model: model,
Messages: []api.Message{
{
Role: "user",
Content: "what does the text in this image say?",
Images: []api.ImageData{
image,
},
},
},
Stream: &stream,
Options: map[string]any{
"seed": 42,
"temperature": 0.0,
},
KeepAlive: &api.Duration{Duration: 10 * time.Second},
}
// Preload to skip if we're less than 80% on GPU to avoid extremely slow tests
err = client.Generate(ctx, &api.GenerateRequest{Model: req.Model}, func(response api.GenerateResponse) error { return nil })
if err != nil {
t.Fatalf("failed to load model %s: %s", req.Model, err)
}
skipIfNotGPULoaded(ctx, t, client, req.Model, 80)
// Note: sometimes it returns "the ollamas" sometimes "the ollams"
// llava models on CPU can be quite slow to start
DoChat(ctx, t, client, req, []string{"the ollam"}, 240*time.Second, 30*time.Second)
})
}
func registerVisionTextCases(models []string) {
registerModelIntegrationCases("vision-text", models, runVisionTextModel)
}
func TestIntegrationSplitBatch(t *testing.T) {
func runVisionTextModel(t *testing.T, model string) {
t.Helper()
skipUnderMinVRAM(t, 6)
skipKnownIntegrationFlake(t, "vision-text", model)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
requireCapability(ctx, t, client, model, "vision")
pullOrSkip(ctx, t, client, model)
image, err := base64.StdEncoding.DecodeString(imageEncoding)
if err != nil {
t.Fatal(err)
}
req := api.ChatRequest{
Model: model,
Messages: []api.Message{
{
Role: "user",
Content: "what does the text in this image say?",
Images: []api.ImageData{
image,
},
},
},
Stream: &stream,
Options: map[string]any{
"seed": 42,
"temperature": 0.0,
},
KeepAlive: &api.Duration{Duration: 10 * time.Second},
}
// Preload to skip if we're less than 80% on GPU to avoid extremely slow tests
preloadGenerateModel(ctx, t, client, api.GenerateRequest{Model: req.Model})
skipIfNotGPULoaded(ctx, t, client, req.Model, 80)
DoChat(ctx, t, client, req, []string{"the ollam", "ollamas"}, 240*time.Second, 30*time.Second)
}
func runIntegrationSplitBatch(t *testing.T, model string) {
if testModel != "" {
t.Skip("uses hardcoded model, not applicable with model override")
}
@@ -83,7 +67,7 @@ func TestIntegrationSplitBatch(t *testing.T) {
t.Fatal(err)
}
req := api.GenerateRequest{
Model: "gemma3:4b",
Model: model,
// Fill up a chunk of the batch so the image will partially spill over into the next one
System: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed aliquet, justo in malesuada lobortis, odio ligula volutpat quam, quis faucibus ipsum magna quis sapien. Aliquam in venenatis diam, eu viverra magna. Phasellus imperdiet hendrerit volutpat. Vivamus sem ex, facilisis placerat felis non, dictum elementum est. Phasellus aliquam imperdiet lacus, eget placerat ligula sodales vel. Pellentesque nec auctor mi. Curabitur arcu nisi, faucibus eget nunc id, viverra interdum mi. Curabitur ornare ipsum ex, ac euismod ex aliquam in. Vestibulum id magna at purus accumsan fermentum. Proin scelerisque posuere nunc quis interdum. Maecenas sed mollis nisl. Etiam vitae ipsum interdum, placerat est quis, tincidunt velit. Nullam tempor nibh non lorem volutpat efficitur. Cras laoreet diam imperdiet ipsum auctor bibendum. Suspendisse ultrices urna sed metus sagittis suscipit. Quisque ullamcorper aliquam nibh ut mollis. Aenean dapibus mauris pharetra, venenatis elit ac, hendrerit odio. Cras vestibulum erat tempor, lobortis justo eu, lobortis ipsum. Nam laoreet dapibus sem. Proin vel diam ultrices, elementum ante et, ornare lectus. Proin eu accumsan nisl. Praesent ac ex vitae ipsum vulputate tristique facilisis sit amet lacus. Nullam faucibus magna a pellentesque pretium. Nunc lacinia ullamcorper sollicitudin. Donec vitae accumsan turpis, sed porttitor est. Donec porttitor mi vitae augue faucibus, vel mollis diam tincidunt.",
Prompt: "what does the text in this image say?",
@@ -108,499 +92,283 @@ func TestIntegrationSplitBatch(t *testing.T) {
DoGenerate(ctx, t, client, req, []string{resp}, 120*time.Second, 30*time.Second)
}
const imageEncoding = `iVBORw0KGgoAAAANSUhEUgAAANIAAAB4CAYAAACHHqzKAAAAAXNSR0IArs4c6QAAAIRlWElmTU0AKgAAAAgABQESAAMAAAABAAEAAAEaAAUAAAABAAAASgEb
AAUAAAABAAAAUgEoAAMAAAABAAIAAIdpAAQAAAABAAAAWgAAAAAAAABIAAAAAQAAAEgAAAABAAOgAQADAAAAAQABAACgAgAEAAAAAQAAANKgAwAEAAAAAQAA
AHgAAAAAXdsepgAAAAlwSFlzAAALEwAACxMBAJqcGAAAAVlpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6
bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDYuMC4wIj4KICAgPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1z
eW50YXgtbnMjIj4KICAgICAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgICAgICAgICAgeG1sbnM6dGlmZj0iaHR0cDovL25zLmFkb2JlLmNv
bS90aWZmLzEuMC8iPgogICAgICAgICA8dGlmZjpPcmllbnRhdGlvbj4xPC90aWZmOk9yaWVudGF0aW9uPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAg
PC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KGV7hBwAAQABJREFUeAGE3QfgX9P5OP6TIRKRncgmS6aR2DNCKEKLqqpRW9FWq0q1dEQparZKF7VK7aq99yZGSCRB
BhErk0Qmyf95nZOTfOqrv/9J7ud977nnPPt5zrz3Ntp0s61XrLnmmql58+Zp6dKlqUWLFmnZsmXp888/Tx07dkwLFy5MX3zxRT4aNWqUmjVrlho3bpzatGmT
Pvnkk5y/YsWKXHfttdfOv/VauSZNmuRj0aJFSX15cIAPruS3adOmafny5Uld5dDkXP05c+akTp06pTXWWCN99tlnacmSJQGnUVp77VbpvffeS126dM4wli4t
dK8RsJoHDvUXL16cy7du3TrjXrBgQS675prNUsu1WgV/AW/ZktSxQ4dMC37BXbDgs7Q4aG7cpHFq2bJlpo984EY/3vELB94k+eqjU36V1fz580OmSyO/WZZt
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`
// imageEncoding is a 210x120 lossless WebP used by the vision integration scenarios.
const imageEncoding = `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`
+1 -1
View File
@@ -16,7 +16,7 @@ import (
"github.com/ollama/ollama/api"
)
func TestMaxQueue(t *testing.T) {
func runMaxQueue(t *testing.T) {
t.Skip("this test needs to be re-evaluated to use a proper embedding model")
if os.Getenv("OLLAMA_TEST_EXISTING") != "" {
-187
View File
@@ -1,187 +0,0 @@
//go:build integration && models
package integration
import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/format"
)
func TestModelsChat(t *testing.T) {
softTimeout, hardTimeout := getTimeouts(t)
slog.Info("Setting timeouts", "soft", softTimeout, "hard", hardTimeout)
ctx, cancel := context.WithTimeout(context.Background(), hardTimeout)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
// TODO use info API eventually
var maxVram uint64
var err error
if s := os.Getenv("OLLAMA_MAX_VRAM"); s != "" {
maxVram, err = strconv.ParseUint(s, 10, 64)
if err != nil {
t.Fatalf("invalid OLLAMA_MAX_VRAM %v", err)
}
} else {
slog.Warn("No VRAM info available, testing all models, so larger ones might timeout...")
}
chatModels := append(ollamaEngineChatModels, llamaRunnerChatModels...)
chatModels = append(chatModels, mlxEngineChatModels...)
for _, model := range testModels(chatModels) {
t.Run(model, func(t *testing.T) {
if time.Now().Sub(started) > softTimeout {
t.Skip("skipping remaining tests to avoid excessive runtime")
}
pullOrSkip(ctx, t, client, model)
if maxVram > 0 {
resp, err := client.List(ctx)
if err != nil {
t.Fatalf("list models failed %v", err)
}
for _, m := range resp.Models {
if m.Name == model && float32(m.Size)*1.2 > float32(maxVram) {
t.Skipf("model %s is too large for available VRAM: %s > %s", model, format.HumanBytes(m.Size), format.HumanBytes(int64(maxVram)))
}
}
}
initialTimeout := 120 * time.Second
streamTimeout := 30 * time.Second
slog.Info("loading", "model", model)
err := client.Generate(ctx,
&api.GenerateRequest{Model: model, KeepAlive: &api.Duration{Duration: 10 * time.Second}},
func(response api.GenerateResponse) error { return nil },
)
if err != nil {
skipIfMLXUnsupported(t, err)
t.Fatalf("failed to load model %s: %s", model, err)
}
gpuPercent := getGPUPercent(ctx, t, client, model)
if gpuPercent < 80 {
slog.Warn("Low GPU percentage - increasing timeouts", "percent", gpuPercent)
initialTimeout = 240 * time.Second
streamTimeout = 40 * time.Second
}
// TODO - fiddle with context size
req := api.ChatRequest{
Model: model,
Messages: []api.Message{
{
Role: "user",
Content: blueSkyPrompt,
},
},
KeepAlive: &api.Duration{Duration: 10 * time.Second},
Options: map[string]interface{}{
"temperature": 0,
"seed": 123,
},
}
DoChat(ctx, t, client, req, blueSkyExpected, initialTimeout, streamTimeout)
// best effort unload once we're done with the model
client.Generate(ctx, &api.GenerateRequest{Model: req.Model, KeepAlive: &api.Duration{Duration: 0}}, func(rsp api.GenerateResponse) error { return nil })
})
}
}
func TestModelsEmbed(t *testing.T) {
softTimeout, hardTimeout := getTimeouts(t)
ctx, cancel := context.WithTimeout(context.Background(), hardTimeout)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
// TODO use info API eventually
var maxVram uint64
var err error
if s := os.Getenv("OLLAMA_MAX_VRAM"); s != "" {
maxVram, err = strconv.ParseUint(s, 10, 64)
if err != nil {
t.Fatalf("invalid OLLAMA_MAX_VRAM %v", err)
}
} else {
slog.Warn("No VRAM info available, testing all models, so larger ones might timeout...")
}
data, err := ioutil.ReadFile(filepath.Join("testdata", "embed.json"))
if err != nil {
t.Fatalf("failed to open test data file: %s", err)
}
testCase := map[string][]float64{}
err = json.Unmarshal(data, &testCase)
if err != nil {
t.Fatalf("failed to load test data: %s", err)
}
for model, expected := range testCase {
if testModel != "" && model != testModel {
continue
}
t.Run(model, func(t *testing.T) {
if time.Now().Sub(started) > softTimeout {
t.Skip("skipping remaining tests to avoid excessive runtime")
}
pullOrSkip(ctx, t, client, model)
if maxVram > 0 {
resp, err := client.List(ctx)
if err != nil {
t.Fatalf("list models failed %v", err)
}
for _, m := range resp.Models {
if m.Name == model && float32(m.Size)*1.2 > float32(maxVram) {
t.Skipf("model %s is too large for available VRAM: %s > %s", model, format.HumanBytes(m.Size), format.HumanBytes(int64(maxVram)))
}
}
}
req := api.EmbeddingRequest{
Model: model,
Prompt: "why is the sky blue?",
KeepAlive: &api.Duration{Duration: 10 * time.Second},
Options: map[string]interface{}{
"temperature": 0,
"seed": 123,
},
}
resp, err := client.Embeddings(ctx, &req)
if err != nil {
t.Fatalf("embeddings call failed %s", err)
}
defer func() {
// best effort unload once we're done with the model
client.Generate(ctx, &api.GenerateRequest{Model: req.Model, KeepAlive: &api.Duration{Duration: 0}}, func(rsp api.GenerateResponse) error { return nil })
}()
if len(resp.Embedding) == 0 {
t.Errorf("zero length embedding response")
}
if len(expected) != len(resp.Embedding) {
expStr := make([]string, len(resp.Embedding))
for i, v := range resp.Embedding {
expStr[i] = fmt.Sprintf("%0.6f", v)
}
// When adding new models, use this output to populate the testdata/embed.json
fmt.Printf("expected\n%s\n", strings.Join(expStr, ", "))
t.Fatalf("expected %d, got %d", len(expected), len(resp.Embedding))
}
sim := cosineSimilarity(resp.Embedding, expected)
if sim < 0.99 {
t.Fatalf("expected %v, got %v (similarity: %f)", expected[0:5], resp.Embedding[0:5], sim)
}
})
}
}
-278
View File
@@ -1,278 +0,0 @@
//go:build integration && perf
package integration
import (
"context"
"fmt"
"io/ioutil"
"log/slog"
"math"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/format"
)
var (
// Models that don't work reliably with the large context prompt in this test case
longContextFlakes = []string{
"granite-code:latest",
"nemotron-mini:latest",
"falcon:latest", // 2k model
"falcon2:latest", // 2k model
"minicpm-v:latest",
"qwen:latest",
}
)
// Note: this test case can take a long time to run, particularly on models with
// large contexts. Run with -timeout set to a large value to get reasonable coverage
// Example usage:
//
// go test --tags=integration,perf -count 1 ./integration -v -timeout 90m -run TestModelsPerf 2>&1 | tee int.log
// cat int.log | grep MODEL_PERF_HEADER | head -1| cut -f2- -d: > perf.csv
// cat int.log | grep MODEL_PERF_DATA | cut -f2- -d: >> perf.csv
func TestModelsPerf(t *testing.T) {
doModelPerfTest(t, append(ollamaEngineChatModels, llamaRunnerChatModels...))
}
func TestLibraryModelsPerf(t *testing.T) {
doModelPerfTest(t, libraryChatModels)
}
func doModelPerfTest(t *testing.T, chatModels []string) {
softTimeout, hardTimeout := getTimeouts(t)
slog.Info("Setting timeouts", "soft", softTimeout, "hard", hardTimeout)
ctx, cancel := context.WithTimeout(context.Background(), hardTimeout)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
// TODO use info API eventually
var maxVram uint64
var err error
if s := os.Getenv("OLLAMA_MAX_VRAM"); s != "" {
maxVram, err = strconv.ParseUint(s, 10, 64)
if err != nil {
t.Fatalf("invalid OLLAMA_MAX_VRAM %v", err)
}
} else {
slog.Warn("No VRAM info available, testing all models, so larger ones might timeout...")
}
data, err := ioutil.ReadFile(filepath.Join("testdata", "shakespeare.txt"))
if err != nil {
t.Fatalf("failed to open test data file: %s", err)
}
longPrompt := "summarize the following: " + string(data)
targetArch := os.Getenv("OLLAMA_TEST_ARCHITECTURE")
for _, model := range chatModels {
if !strings.Contains(model, ":") {
model = model + ":latest"
}
t.Run(model, func(t *testing.T) {
if time.Now().Sub(started) > softTimeout {
t.Skip("skipping remaining tests to avoid excessive runtime")
}
pullOrSkip(ctx, t, client, model)
var maxContext int
resp, err := client.Show(ctx, &api.ShowRequest{Model: model})
if err != nil {
t.Fatalf("show failed: %s", err)
}
arch := resp.ModelInfo["general.architecture"].(string)
maxContext = int(resp.ModelInfo[fmt.Sprintf("%s.context_length", arch)].(float64))
if targetArch != "" && arch != targetArch {
t.Skip(fmt.Sprintf("Skipping %s architecture %s != %s", model, arch, targetArch))
}
if maxVram > 0 {
resp, err := client.List(ctx)
if err != nil {
t.Fatalf("list models failed %v", err)
}
for _, m := range resp.Models {
// For these tests we want to exercise a some amount of overflow on the CPU
if m.Name == model && float32(m.Size)*0.75 > float32(maxVram) {
t.Skipf("model %s is too large %s for available VRAM %s", model, format.HumanBytes(m.Size), format.HumanBytes(int64(maxVram)))
}
}
}
slog.Info("scneario", "model", model, "max_context", maxContext)
loaded := false
defer func() {
// best effort unload once we're done with the model
if loaded {
client.Generate(ctx, &api.GenerateRequest{Model: model, KeepAlive: &api.Duration{Duration: 0}}, func(rsp api.GenerateResponse) error { return nil })
}
}()
// Some models don't handle the long context data well so skip them to avoid flaky test results
longContextFlake := false
for _, flake := range longContextFlakes {
if model == flake {
longContextFlake = true
break
}
}
// iterate through a few context sizes for coverage without excessive runtime
var contexts []int
keepGoing := true
if maxContext > 16384 {
contexts = []int{4096, 8192, 16384, maxContext}
} else if maxContext > 8192 {
contexts = []int{4096, 8192, maxContext}
} else if maxContext > 4096 {
contexts = []int{4096, maxContext}
} else if maxContext > 0 {
contexts = []int{maxContext}
} else {
t.Fatal("unknown max context size")
}
for _, numCtx := range contexts {
if !keepGoing && numCtx > 8192 { // Always try up to 8k before bailing out
break
}
skipLongPrompt := false
// Workaround bug 11172 temporarily...
maxPrompt := longPrompt
// If we fill the context too full with the prompt, many models
// quickly hit context shifting and go bad.
if len(maxPrompt) > numCtx*2 { // typically yields ~1/2 full context
maxPrompt = maxPrompt[:numCtx*2]
}
testCases := []struct {
prompt string
anyResp []string
}{
{blueSkyPrompt, blueSkyExpected},
{maxPrompt, []string{"shakespeare", "oppression", "sorrows", "gutenberg", "child", "license", "sonnet", "melancholy", "love", "sorrow", "beauty"}},
}
var gpuPercent int
for _, tc := range testCases {
if len(tc.prompt) > 100 && (longContextFlake || skipLongPrompt) {
slog.Info("skipping long prompt", "model", model, "num_ctx", numCtx, "gpu_percent", gpuPercent)
continue
}
req := api.ChatRequest{
Model: model,
Messages: []api.Message{
{
Role: "user",
Content: tc.prompt,
},
},
KeepAlive: &api.Duration{Duration: 20 * time.Second}, // long enough to ensure a ps returns
Options: map[string]interface{}{
"temperature": 0,
"seed": 123,
"num_ctx": numCtx,
},
}
atLeastOne := false
var resp api.ChatResponse
stream := false
req.Stream = &stream
// Avoid potentially getting stuck indefinitely
limit := 5 * time.Minute
genCtx, cancel := context.WithDeadlineCause(
ctx,
time.Now().Add(limit),
fmt.Errorf("generate on model %s with ctx %d took longer than %v", model, numCtx, limit),
)
defer cancel()
err = client.Chat(genCtx, &req, func(rsp api.ChatResponse) error {
resp = rsp
return nil
})
if err != nil {
// Avoid excessive test runs, but don't consider a failure with massive context
if numCtx > 16384 && strings.Contains(err.Error(), "took longer") {
slog.Warn("max context was taking too long, skipping", "error", err)
keepGoing = false
skipLongPrompt = true
continue
}
t.Fatalf("generate error: ctx:%d err:%s", numCtx, err)
}
loaded = true
for _, expResp := range tc.anyResp {
if strings.Contains(strings.ToLower(resp.Message.Content), expResp) {
atLeastOne = true
break
}
}
if !atLeastOne {
t.Fatalf("response didn't contain expected values: ctx:%d expected:%v response:%s ", numCtx, tc.anyResp, resp.Message.Content)
}
models, err := client.ListRunning(ctx)
if err != nil {
slog.Warn("failed to list running models", "error", err)
continue
}
if len(models.Models) > 1 {
slog.Warn("multiple models loaded, may impact performance results", "loaded", models.Models)
}
for _, m := range models.Models {
if m.Name == model {
if m.SizeVRAM == 0 {
slog.Info("Model fully loaded into CPU")
gpuPercent = 0
keepGoing = false
skipLongPrompt = true
} else if m.SizeVRAM == m.Size {
slog.Info("Model fully loaded into GPU")
gpuPercent = 100
} else {
sizeCPU := m.Size - m.SizeVRAM
cpuPercent := math.Round(float64(sizeCPU) / float64(m.Size) * 100)
gpuPercent = int(100 - cpuPercent)
slog.Info("Model split between CPU/GPU", "CPU", cpuPercent, "GPU", gpuPercent)
keepGoing = false
// Heuristic to avoid excessive test run time
if gpuPercent < 90 {
skipLongPrompt = true
}
}
}
}
// Round the logged prompt count for comparisons across versions/configurations which can vary slightly
fmt.Fprintf(os.Stderr, "MODEL_PERF_HEADER:%s,%s,%s,%s,%s,%s,%s\n",
"MODEL",
"CONTEXT",
"GPU PERCENT",
"APPROX PROMPT COUNT",
"LOAD TIME",
"PROMPT EVAL TPS",
"EVAL TPS",
)
fmt.Fprintf(os.Stderr, "MODEL_PERF_DATA:%s,%d,%d,%d,%0.2f,%0.2f,%0.2f\n",
model,
numCtx,
gpuPercent,
(resp.PromptEvalCount/10)*10,
float64(resp.LoadDuration)/1000000000.0,
float64(resp.PromptEvalCount)/(float64(resp.PromptEvalDuration)/1000000000.0),
float64(resp.EvalCount)/(float64(resp.EvalDuration)/1000000000.0),
)
}
}
})
}
}
+6 -2
View File
@@ -1,4 +1,4 @@
//go:build integration && models
//go:build integration && release
package integration
@@ -14,7 +14,11 @@ import (
"github.com/ollama/ollama/api"
)
func TestQuantization(t *testing.T) {
func runQuantization(t *testing.T) {
if testModel != "" {
t.Skip("exercises quantization with a fixed source model, not applicable with model override")
}
sourceModels := []string{
"qwen2.5:0.5b-instruct-fp16",
}
Loaded 100 of 359 files, more files were not shown because too many files have changed in this diff. Show more