mirror of
https://github.com/ollama/ollama.git
synced 2026-09-11 05:33:32 -04:00
Compare commits
64
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a67e30cf4e | ||
|
|
283b393ed9 | ||
|
|
1b3a200c25 | ||
|
|
f4438d8215 | ||
|
|
5d920cc6bc | ||
|
|
cdddea0592 | ||
|
|
43f90def04 | ||
|
|
06ae6367bd | ||
|
|
48ad7085c4 | ||
|
|
e1e3cec8d0 | ||
|
|
d3e67e305c | ||
|
|
698e04a14b | ||
|
|
1d9537bc33 | ||
|
|
120424d832 | ||
|
|
5818001610 | ||
|
|
2cba7756c5 | ||
|
|
bf2a421727 | ||
|
|
f3cf6b75fb | ||
|
|
5dfac387a6 | ||
|
|
a99e5d9c22 | ||
|
|
0abf3aca36 | ||
|
|
ee0266462a | ||
|
|
c88fb286ec | ||
|
|
d3da29cbfc | ||
|
|
1b70bb8a10 | ||
|
|
ec29ce4ce3 | ||
|
|
4d75f5da03 | ||
|
|
798fd09bfe | ||
|
|
9330bb9120 | ||
|
|
40a1317dfd | ||
|
|
fdfe9cec98 | ||
|
|
9517864603 | ||
|
|
8e6d86dbe3 | ||
|
|
80d3744c5d | ||
|
|
2a94f03823 | ||
|
|
eb97274e5c | ||
|
|
6b5db12aa2 | ||
|
|
612f0a17d3 | ||
|
|
673726fa0e | ||
|
|
b5918f9785 | ||
|
|
d17f482d50 | ||
|
|
4e16f562c0 | ||
|
|
55308f1421 | ||
|
|
d64812eb5d | ||
|
|
f86a969f27 | ||
|
|
9fa80a1660 | ||
|
|
dde09129d1 | ||
|
|
780556c4d0 | ||
|
|
dfae363b5b | ||
|
|
30fdd229a4 | ||
|
|
e823bff873 | ||
|
|
8968740836 | ||
|
|
8c8f8f3450 | ||
|
|
82f0139587 | ||
|
|
26a58b294c | ||
|
|
34a790a2e6 | ||
|
|
4589fa2cf5 | ||
|
|
4bc2728047 | ||
|
|
49d5fd5a3e | ||
|
|
3cd2b03a5e | ||
|
|
c8e0878814 | ||
|
|
bb0c58e134 | ||
|
|
036ed1b9b5 | ||
|
|
3536ef58f6 |
No files matched your search
@@ -27,7 +27,7 @@ jobs:
|
||||
echo vendorsha=$(make -f Makefile.sync print-base) | tee -a $GITHUB_OUTPUT
|
||||
|
||||
darwin-build:
|
||||
runs-on: macos-14-xlarge
|
||||
runs-on: macos-26-xlarge
|
||||
environment: release
|
||||
needs: setup-environment
|
||||
env:
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
container: nvidia/cuda:13.0.0-devel-ubuntu22.04
|
||||
flags: '-DCMAKE_CUDA_ARCHITECTURES=87'
|
||||
- preset: ROCm
|
||||
container: rocm/dev-ubuntu-22.04:7.2
|
||||
container: rocm/dev-ubuntu-22.04:7.2.1
|
||||
extra-packages: rocm-libs
|
||||
flags: '-DAMDGPU_TARGETS=gfx1010 -DCMAKE_PREFIX_PATH=/opt/rocm'
|
||||
- preset: Vulkan
|
||||
|
||||
@@ -15,3 +15,4 @@ __debug_bin*
|
||||
llama/build
|
||||
llama/vendor
|
||||
/ollama
|
||||
integration/testdata/models/
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
ARG FLAVOR=${TARGETARCH}
|
||||
|
||||
ARG ROCMVERSION=7.2
|
||||
ARG ROCMVERSION=7.2.1
|
||||
ARG JETPACK5VERSION=r35.4.1
|
||||
ARG JETPACK6VERSION=r36.4.0
|
||||
ARG CMAKEVERSION=3.31.2
|
||||
|
||||
@@ -55,7 +55,7 @@ The official [Ollama Docker image](https://hub.docker.com/r/ollama/ollama) `olla
|
||||
ollama
|
||||
```
|
||||
|
||||
You'll be prompted to run a model or connect Ollama to your existing agents or applications such as `claude`, `codex`, `openclaw` and more.
|
||||
You'll be prompted to run a model or connect Ollama to your existing agents or applications such as `Claude Code`, `OpenClaw`, `OpenCode` , `Codex`, `Copilot`, and more.
|
||||
|
||||
### Coding
|
||||
|
||||
@@ -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), [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), [Droid](https://docs.ollama.com/integrations/droid), and [OpenCode](https://docs.ollama.com/integrations/opencode).
|
||||
|
||||
### AI assistant
|
||||
|
||||
|
||||
@@ -1211,7 +1211,7 @@ func (db *database) setSettings(s Settings) error {
|
||||
}
|
||||
if lastHomeView != "chat" {
|
||||
if _, ok := validLaunchView[lastHomeView]; !ok {
|
||||
lastHomeView = "chat"
|
||||
lastHomeView = "launch"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -135,6 +135,45 @@ func TestMigrationV13ToV14ContextLength(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationV15ToV16LastHomeViewDefaultsToLaunch(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
|
||||
db, err := newDatabase(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if _, err := db.conn.Exec(`
|
||||
ALTER TABLE settings DROP COLUMN last_home_view;
|
||||
UPDATE settings SET schema_version = 15;
|
||||
`); err != nil {
|
||||
t.Fatalf("failed to seed v15 settings row: %v", err)
|
||||
}
|
||||
|
||||
if err := db.migrate(); err != nil {
|
||||
t.Fatalf("migration from v15 to v16 failed: %v", err)
|
||||
}
|
||||
|
||||
var lastHomeView string
|
||||
if err := db.conn.QueryRow("SELECT last_home_view FROM settings").Scan(&lastHomeView); err != nil {
|
||||
t.Fatalf("failed to read last_home_view: %v", err)
|
||||
}
|
||||
|
||||
if lastHomeView != "launch" {
|
||||
t.Fatalf("expected last_home_view to default to launch after migration, got %q", lastHomeView)
|
||||
}
|
||||
|
||||
version, err := db.getSchemaVersion()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get schema version: %v", err)
|
||||
}
|
||||
if version != currentSchemaVersion {
|
||||
t.Fatalf("expected schema version %d, got %d", currentSchemaVersion, version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatDeletionWithCascade(t *testing.T) {
|
||||
t.Run("chat deletion cascades to related messages", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
@@ -81,6 +81,32 @@ func TestStore(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("settings default home view is launch", func(t *testing.T) {
|
||||
loaded, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if loaded.LastHomeView != "launch" {
|
||||
t.Fatalf("expected default LastHomeView to be launch, got %q", loaded.LastHomeView)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("settings empty home view falls back to launch", func(t *testing.T) {
|
||||
if err := s.SetSettings(Settings{LastHomeView: ""}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if loaded.LastHomeView != "launch" {
|
||||
t.Fatalf("expected empty LastHomeView to fall back to launch, got %q", loaded.LastHomeView)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("window size", func(t *testing.T) {
|
||||
if err := s.SetWindowSize(1024, 768); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -480,13 +480,15 @@ function ChatForm({
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare attachments for submission
|
||||
const attachmentsToSend: FileAttachment[] = message.attachments.map(
|
||||
(att) => ({
|
||||
// Prepare attachments for submission, excluding unsupported images
|
||||
const attachmentsToSend: FileAttachment[] = message.attachments
|
||||
.filter(
|
||||
(att) => hasVisionCapability || !isImageFile(att.filename),
|
||||
)
|
||||
.map((att) => ({
|
||||
filename: att.filename,
|
||||
data: att.data || new Uint8Array(0), // Empty data for existing files
|
||||
}),
|
||||
);
|
||||
}));
|
||||
|
||||
const useWebSearch =
|
||||
supportsWebSearch && webSearchEnabled && !cloudDisabled;
|
||||
@@ -736,10 +738,17 @@ function ChatForm({
|
||||
)}
|
||||
{(message.attachments.length > 0 || message.fileErrors.length > 0) && (
|
||||
<div className="flex gap-2 overflow-x-auto px-3 pt pb-3 w-full scrollbar-hide">
|
||||
{message.attachments.map((attachment, index) => (
|
||||
{message.attachments.map((attachment, index) => {
|
||||
const isUnsupportedImage =
|
||||
!hasVisionCapability && isImageFile(attachment.filename);
|
||||
return (
|
||||
<div
|
||||
key={attachment.id}
|
||||
className="group flex items-center gap-2 py-2 px-3 rounded-lg bg-neutral-50 dark:bg-neutral-700/50 hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors flex-shrink-0"
|
||||
className={`group flex items-center gap-2 py-2 px-3 rounded-lg transition-colors flex-shrink-0 ${
|
||||
isUnsupportedImage
|
||||
? "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800"
|
||||
: "bg-neutral-50 dark:bg-neutral-700/50 hover:bg-neutral-100 dark:hover:bg-neutral-700"
|
||||
}`}
|
||||
>
|
||||
{isImageFile(attachment.filename) ? (
|
||||
<ImageThumbnail
|
||||
@@ -764,9 +773,16 @@ function ChatForm({
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
<span className="text-sm text-neutral-700 dark:text-neutral-300 max-w-[150px] truncate">
|
||||
{attachment.filename}
|
||||
</span>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className={`text-sm max-w-36 truncate ${isUnsupportedImage ? "text-red-700 dark:text-red-300" : "text-neutral-700 dark:text-neutral-300"}`}>
|
||||
{attachment.filename}
|
||||
</span>
|
||||
{isUnsupportedImage && (
|
||||
<span className="text-xs text-red-600 dark:text-red-400 opacity-75">
|
||||
This model does not support images
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFile(index)}
|
||||
@@ -788,7 +804,8 @@ function ChatForm({
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{message.fileErrors.map((fileError, index) => (
|
||||
<div
|
||||
key={`error-${index}`}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { CogIcon, RocketLaunchIcon } from "@heroicons/react/24/outline";
|
||||
// holding shift and clicking this many times within this many seconds
|
||||
const DEBUG_SHIFT_CLICKS_REQUIRED = 5;
|
||||
const DEBUG_SHIFT_CLICK_WINDOW_MS = 7000; // 7 seconds
|
||||
const launchSidebarRequestedKey = "ollama.launchSidebarRequested";
|
||||
|
||||
interface ChatSidebarProps {
|
||||
currentChatId?: string;
|
||||
@@ -285,6 +286,11 @@ export function ChatSidebar({ currentChatId }: ChatSidebarProps) {
|
||||
<Link
|
||||
to="/c/$chatId"
|
||||
params={{ chatId: "launch" }}
|
||||
onClick={() => {
|
||||
if (currentChatId !== "launch") {
|
||||
sessionStorage.setItem(launchSidebarRequestedKey, "1");
|
||||
}
|
||||
}}
|
||||
className={`flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-800 dark:text-neutral-100 cursor-pointer ${currentChatId === "launch"
|
||||
? "bg-neutral-100 dark:bg-neutral-800"
|
||||
: ""
|
||||
|
||||
@@ -536,7 +536,7 @@ function ToolCallDisplay({
|
||||
let args: Record<string, unknown> | null = null;
|
||||
try {
|
||||
args = JSON.parse(toolCall.function.arguments) as Record<string, unknown>;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
args = null;
|
||||
}
|
||||
const query = args && typeof args.query === "string" ? args.query : "";
|
||||
@@ -562,7 +562,7 @@ function ToolCallDisplay({
|
||||
let args: Record<string, unknown> | null = null;
|
||||
try {
|
||||
args = JSON.parse(toolCall.function.arguments) as Record<string, unknown>;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
args = null;
|
||||
}
|
||||
const url = args && typeof args.url === "string" ? args.url : "";
|
||||
|
||||
@@ -73,7 +73,7 @@ export default function MessageList({
|
||||
? String(args.url).trim()
|
||||
: "";
|
||||
if (candidate) lastQuery = candidate;
|
||||
} catch {}
|
||||
} catch { /* ignored */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export const BadgeButton = forwardRef(function BadgeButton(
|
||||
),
|
||||
ref: React.ForwardedRef<HTMLElement>,
|
||||
) {
|
||||
let classes = clsx(
|
||||
const classes = clsx(
|
||||
className,
|
||||
"group relative inline-flex rounded-md focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500",
|
||||
);
|
||||
|
||||
@@ -171,7 +171,7 @@ export const Button = forwardRef(function Button(
|
||||
{ color, outline, plain, className, children, ...props }: ButtonProps,
|
||||
ref: React.ForwardedRef<HTMLElement>,
|
||||
) {
|
||||
let classes = clsx(
|
||||
const classes = clsx(
|
||||
className,
|
||||
styles.base,
|
||||
outline
|
||||
|
||||
@@ -5,9 +5,31 @@ import { getChat } from "@/api";
|
||||
import { SidebarLayout } from "@/components/layout/layout";
|
||||
import { ChatSidebar } from "@/components/ChatSidebar";
|
||||
import LaunchCommands from "@/components/LaunchCommands";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
|
||||
const launchSidebarRequestedKey = "ollama.launchSidebarRequested";
|
||||
const launchSidebarSeenKey = "ollama.launchSidebarSeen";
|
||||
const fallbackSessionState = new Map<string, string>();
|
||||
|
||||
function getSessionState() {
|
||||
if (typeof sessionStorage !== "undefined") {
|
||||
return sessionStorage;
|
||||
}
|
||||
|
||||
return {
|
||||
getItem(key: string) {
|
||||
return fallbackSessionState.get(key) ?? null;
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
fallbackSessionState.set(key, value);
|
||||
},
|
||||
removeItem(key: string) {
|
||||
fallbackSessionState.delete(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/c/$chatId")({
|
||||
component: RouteComponent,
|
||||
loader: async ({ context, params }) => {
|
||||
@@ -25,6 +47,7 @@ export const Route = createFileRoute("/c/$chatId")({
|
||||
function RouteComponent() {
|
||||
const { chatId } = Route.useParams();
|
||||
const { settingsData, setSettings } = useSettings();
|
||||
const previousChatIdRef = useRef<string | null>(null);
|
||||
|
||||
// Always call hooks at the top level - use a flag to skip data when chatId is a special view
|
||||
const {
|
||||
@@ -38,15 +61,42 @@ function RouteComponent() {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousChatId = previousChatIdRef.current;
|
||||
previousChatIdRef.current = chatId;
|
||||
|
||||
if (chatId === "launch") {
|
||||
if (
|
||||
settingsData.LastHomeView !== "chat" &&
|
||||
settingsData.LastHomeView !== "launch"
|
||||
) {
|
||||
const sessionState = getSessionState();
|
||||
const shouldOpenSidebar =
|
||||
previousChatId !== "launch" &&
|
||||
(() => {
|
||||
if (sessionState.getItem(launchSidebarRequestedKey) === "1") {
|
||||
sessionState.removeItem(launchSidebarRequestedKey);
|
||||
sessionState.setItem(launchSidebarSeenKey, "1");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sessionState.getItem(launchSidebarSeenKey) !== "1") {
|
||||
sessionState.setItem(launchSidebarSeenKey, "1");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
})();
|
||||
const updates: { LastHomeView?: string; SidebarOpen?: boolean } = {};
|
||||
|
||||
if (settingsData.LastHomeView !== "launch") {
|
||||
updates.LastHomeView = "launch";
|
||||
}
|
||||
|
||||
if (shouldOpenSidebar && !settingsData.SidebarOpen) {
|
||||
updates.SidebarOpen = true;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSettings({ LastHomeView: "openclaw" }).catch(() => {
|
||||
setSettings(updates).catch(() => {
|
||||
// Best effort persistence for home view preference.
|
||||
});
|
||||
return;
|
||||
|
||||
@@ -7,7 +7,8 @@ export const Route = createFileRoute("/")({
|
||||
queryKey: ["settings"],
|
||||
queryFn: getSettings,
|
||||
});
|
||||
const chatId = settingsData?.settings?.LastHomeView === "chat" ? "new" : "launch";
|
||||
const chatId =
|
||||
settingsData?.settings?.LastHomeView === "chat" ? "new" : "launch";
|
||||
|
||||
throw redirect({
|
||||
to: "/c/$chatId",
|
||||
|
||||
@@ -29,13 +29,15 @@ describe("fileValidation", () => {
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject WebP images when vision capability is disabled", () => {
|
||||
it("should accept images regardless of vision capability", () => {
|
||||
// Vision capability check is handled at the UI layer (ChatForm),
|
||||
// not at validation time, so users can switch models without
|
||||
// needing to re-upload files.
|
||||
const file = createMockFile("test.webp", 1024, "image/webp");
|
||||
const result = validateFile(file, {
|
||||
hasVisionCapability: false,
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe("This model does not support images");
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept PNG images when vision capability is enabled", () => {
|
||||
|
||||
@@ -63,7 +63,6 @@ export function validateFile(
|
||||
const {
|
||||
maxFileSize = 10,
|
||||
allowedExtensions = [...TEXT_FILE_EXTENSIONS, ...IMAGE_EXTENSIONS],
|
||||
hasVisionCapability = false,
|
||||
customValidator,
|
||||
} = options;
|
||||
|
||||
@@ -83,10 +82,6 @@ export function validateFile(
|
||||
return { valid: false, error: "File type not supported" };
|
||||
}
|
||||
|
||||
if (IMAGE_EXTENSIONS.includes(fileExtension) && !hasVisionCapability) {
|
||||
return { valid: false, error: "This model does not support images" };
|
||||
}
|
||||
|
||||
// File size validation
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return { valid: false, error: "File too large" };
|
||||
|
||||
@@ -2,27 +2,28 @@ import { Model } from "@/gotypes";
|
||||
|
||||
// Featured models list (in priority order)
|
||||
export const FEATURED_MODELS = [
|
||||
"kimi-k2.5:cloud",
|
||||
"glm-5:cloud",
|
||||
"minimax-m2.7:cloud",
|
||||
"gemma4:31b-cloud",
|
||||
"qwen3.5:397b-cloud",
|
||||
"gpt-oss:120b-cloud",
|
||||
"gpt-oss:20b-cloud",
|
||||
"deepseek-v3.1:671b-cloud",
|
||||
"qwen3-coder:480b-cloud",
|
||||
"qwen3-vl:235b-cloud",
|
||||
"minimax-m2:cloud",
|
||||
"glm-4.6:cloud",
|
||||
"gpt-oss:120b",
|
||||
"gpt-oss:20b",
|
||||
"gemma3:27b",
|
||||
"gemma3:12b",
|
||||
"gemma3:4b",
|
||||
"gemma3:1b",
|
||||
"gemma4:31b",
|
||||
"gemma4:26b",
|
||||
"gemma4:e4b",
|
||||
"gemma4:e2b",
|
||||
"deepseek-r1:8b",
|
||||
"qwen3-coder:30b",
|
||||
"qwen3-vl:30b",
|
||||
"qwen3-vl:8b",
|
||||
"qwen3-vl:4b",
|
||||
"qwen3:30b",
|
||||
"qwen3:8b",
|
||||
"qwen3:4b",
|
||||
"qwen3.5:27b",
|
||||
"qwen3.5:9b",
|
||||
"qwen3.5:4b",
|
||||
];
|
||||
|
||||
function alphabeticalSort(a: Model, b: Model): number {
|
||||
|
||||
+46
-49
@@ -54,7 +54,6 @@ import (
|
||||
"github.com/ollama/ollama/types/syncmap"
|
||||
"github.com/ollama/ollama/version"
|
||||
xcmd "github.com/ollama/ollama/x/cmd"
|
||||
"github.com/ollama/ollama/x/create"
|
||||
xcreateclient "github.com/ollama/ollama/x/create/client"
|
||||
"github.com/ollama/ollama/x/imagegen"
|
||||
)
|
||||
@@ -93,13 +92,7 @@ func init() {
|
||||
return userName, err
|
||||
}
|
||||
|
||||
launch.DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
ok, err := tui.RunConfirm(prompt)
|
||||
if errors.Is(err, tui.ErrCancelled) {
|
||||
return false, launch.ErrCancelled
|
||||
}
|
||||
return ok, err
|
||||
}
|
||||
launch.DefaultConfirmPrompt = tui.RunConfirmWithOptions
|
||||
}
|
||||
|
||||
const ConnectInstructions = "If your browser did not open, navigate to:\n %s\n\n"
|
||||
@@ -164,11 +157,13 @@ func CreateHandler(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
// Check for --experimental flag for safetensors model creation
|
||||
// This gates both safetensors LLM and imagegen model creation
|
||||
experimental, _ := cmd.Flags().GetBool("experimental")
|
||||
if experimental {
|
||||
if !isLocalhost() {
|
||||
return errors.New("remote safetensor model creation not yet supported")
|
||||
}
|
||||
|
||||
// Get Modelfile content - either from -f flag or default to "FROM ."
|
||||
var reader io.Reader
|
||||
filename, err := getModelfileName(cmd)
|
||||
@@ -211,23 +206,12 @@ func CreateHandler(cmd *cobra.Command, args []string) error {
|
||||
}, p)
|
||||
}
|
||||
|
||||
// Standard Modelfile + API path
|
||||
var reader io.Reader
|
||||
|
||||
filename, err := getModelfileName(cmd)
|
||||
if os.IsNotExist(err) {
|
||||
if filename == "" {
|
||||
// No Modelfile found - check if current directory is an image gen model
|
||||
if create.IsTensorModelDir(".") {
|
||||
if !isLocalhost() {
|
||||
return errors.New("remote safetensor model creation not yet supported")
|
||||
}
|
||||
quantize, _ := cmd.Flags().GetString("quantize")
|
||||
return xcreateclient.CreateModel(xcreateclient.CreateOptions{
|
||||
ModelName: modelName,
|
||||
ModelDir: ".",
|
||||
Quantize: quantize,
|
||||
}, p)
|
||||
}
|
||||
reader = strings.NewReader("FROM .\n")
|
||||
} else {
|
||||
return errModelfileNotFound
|
||||
@@ -711,7 +695,7 @@ func RunHandler(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
opts.ParentModel = info.Details.ParentModel
|
||||
applyShowResponseToRunOptions(&opts, info)
|
||||
|
||||
// Check if this is an embedding model
|
||||
isEmbeddingModel := slices.Contains(info.Capabilities, model.CapabilityEmbedding)
|
||||
@@ -1427,23 +1411,30 @@ func PullHandler(cmd *cobra.Command, args []string) error {
|
||||
type generateContextKey string
|
||||
|
||||
type runOptions struct {
|
||||
Model string
|
||||
ParentModel string
|
||||
Prompt string
|
||||
Messages []api.Message
|
||||
WordWrap bool
|
||||
Format string
|
||||
System string
|
||||
Images []api.ImageData
|
||||
Options map[string]any
|
||||
MultiModal bool
|
||||
KeepAlive *api.Duration
|
||||
Think *api.ThinkValue
|
||||
HideThinking bool
|
||||
ShowConnect bool
|
||||
Model string
|
||||
ParentModel string
|
||||
LoadedMessages []api.Message
|
||||
Prompt string
|
||||
Messages []api.Message
|
||||
WordWrap bool
|
||||
Format string
|
||||
System string
|
||||
Images []api.ImageData
|
||||
Options map[string]any
|
||||
MultiModal bool
|
||||
KeepAlive *api.Duration
|
||||
Think *api.ThinkValue
|
||||
HideThinking bool
|
||||
ShowConnect bool
|
||||
}
|
||||
|
||||
func (r runOptions) Copy() runOptions {
|
||||
var loadedMessages []api.Message
|
||||
if r.LoadedMessages != nil {
|
||||
loadedMessages = make([]api.Message, len(r.LoadedMessages))
|
||||
copy(loadedMessages, r.LoadedMessages)
|
||||
}
|
||||
|
||||
var messages []api.Message
|
||||
if r.Messages != nil {
|
||||
messages = make([]api.Message, len(r.Messages))
|
||||
@@ -1471,23 +1462,29 @@ func (r runOptions) Copy() runOptions {
|
||||
}
|
||||
|
||||
return runOptions{
|
||||
Model: r.Model,
|
||||
ParentModel: r.ParentModel,
|
||||
Prompt: r.Prompt,
|
||||
Messages: messages,
|
||||
WordWrap: r.WordWrap,
|
||||
Format: r.Format,
|
||||
System: r.System,
|
||||
Images: images,
|
||||
Options: opts,
|
||||
MultiModal: r.MultiModal,
|
||||
KeepAlive: r.KeepAlive,
|
||||
Think: think,
|
||||
HideThinking: r.HideThinking,
|
||||
ShowConnect: r.ShowConnect,
|
||||
Model: r.Model,
|
||||
ParentModel: r.ParentModel,
|
||||
LoadedMessages: loadedMessages,
|
||||
Prompt: r.Prompt,
|
||||
Messages: messages,
|
||||
WordWrap: r.WordWrap,
|
||||
Format: r.Format,
|
||||
System: r.System,
|
||||
Images: images,
|
||||
Options: opts,
|
||||
MultiModal: r.MultiModal,
|
||||
KeepAlive: r.KeepAlive,
|
||||
Think: think,
|
||||
HideThinking: r.HideThinking,
|
||||
ShowConnect: r.ShowConnect,
|
||||
}
|
||||
}
|
||||
|
||||
func applyShowResponseToRunOptions(opts *runOptions, info *api.ShowResponse) {
|
||||
opts.ParentModel = info.Details.ParentModel
|
||||
opts.LoadedMessages = slices.Clone(info.Messages)
|
||||
}
|
||||
|
||||
type displayResponseState struct {
|
||||
lineLength int
|
||||
wordBuffer string
|
||||
|
||||
+74
-10
@@ -1655,6 +1655,24 @@ func TestNewCreateRequest(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"loaded messages are preserved when saving",
|
||||
"newmodel",
|
||||
runOptions{
|
||||
Model: "mymodel",
|
||||
ParentModel: "parentmodel",
|
||||
LoadedMessages: []api.Message{{Role: "assistant", Content: "loaded"}},
|
||||
Messages: []api.Message{{Role: "user", Content: "new"}},
|
||||
},
|
||||
&api.CreateRequest{
|
||||
From: "parentmodel",
|
||||
Model: "newmodel",
|
||||
Messages: []api.Message{
|
||||
{Role: "assistant", Content: "loaded"},
|
||||
{Role: "user", Content: "new"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -1667,15 +1685,43 @@ func TestNewCreateRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyShowResponseToRunOptions(t *testing.T) {
|
||||
opts := runOptions{}
|
||||
info := &api.ShowResponse{
|
||||
Details: api.ModelDetails{
|
||||
ParentModel: "parentmodel",
|
||||
},
|
||||
Messages: []api.Message{
|
||||
{Role: "assistant", Content: "loaded"},
|
||||
},
|
||||
}
|
||||
|
||||
applyShowResponseToRunOptions(&opts, info)
|
||||
|
||||
if opts.ParentModel != "parentmodel" {
|
||||
t.Fatalf("ParentModel = %q, want %q", opts.ParentModel, "parentmodel")
|
||||
}
|
||||
|
||||
if !cmp.Equal(opts.LoadedMessages, info.Messages) {
|
||||
t.Fatalf("LoadedMessages = %#v, want %#v", opts.LoadedMessages, info.Messages)
|
||||
}
|
||||
|
||||
info.Messages[0].Content = "modified"
|
||||
if opts.LoadedMessages[0].Content == "modified" {
|
||||
t.Fatal("LoadedMessages should be copied independently from ShowResponse")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunOptions_Copy(t *testing.T) {
|
||||
// Setup test data
|
||||
originalKeepAlive := &api.Duration{Duration: 5 * time.Minute}
|
||||
originalThink := &api.ThinkValue{Value: "test reasoning"}
|
||||
|
||||
original := runOptions{
|
||||
Model: "test-model",
|
||||
ParentModel: "parent-model",
|
||||
Prompt: "test prompt",
|
||||
Model: "test-model",
|
||||
ParentModel: "parent-model",
|
||||
LoadedMessages: []api.Message{{Role: "assistant", Content: "loaded hello"}},
|
||||
Prompt: "test prompt",
|
||||
Messages: []api.Message{
|
||||
{Role: "user", Content: "hello"},
|
||||
{Role: "assistant", Content: "hi there"},
|
||||
@@ -1715,6 +1761,7 @@ func TestRunOptions_Copy(t *testing.T) {
|
||||
}{
|
||||
{"Model", copied.Model, original.Model},
|
||||
{"ParentModel", copied.ParentModel, original.ParentModel},
|
||||
{"LoadedMessages", copied.LoadedMessages, original.LoadedMessages},
|
||||
{"Prompt", copied.Prompt, original.Prompt},
|
||||
{"WordWrap", copied.WordWrap, original.WordWrap},
|
||||
{"Format", copied.Format, original.Format},
|
||||
@@ -1819,13 +1866,18 @@ func TestRunOptions_Copy(t *testing.T) {
|
||||
func TestRunOptions_Copy_EmptySlicesAndMaps(t *testing.T) {
|
||||
// Test with empty slices and maps
|
||||
original := runOptions{
|
||||
Messages: []api.Message{},
|
||||
Images: []api.ImageData{},
|
||||
Options: map[string]any{},
|
||||
LoadedMessages: []api.Message{},
|
||||
Messages: []api.Message{},
|
||||
Images: []api.ImageData{},
|
||||
Options: map[string]any{},
|
||||
}
|
||||
|
||||
copied := original.Copy()
|
||||
|
||||
if copied.LoadedMessages == nil {
|
||||
t.Error("Empty LoadedMessages slice should remain empty, not nil")
|
||||
}
|
||||
|
||||
if copied.Messages == nil {
|
||||
t.Error("Empty Messages slice should remain empty, not nil")
|
||||
}
|
||||
@@ -1842,6 +1894,10 @@ func TestRunOptions_Copy_EmptySlicesAndMaps(t *testing.T) {
|
||||
t.Error("Empty Messages slice should remain empty")
|
||||
}
|
||||
|
||||
if len(copied.LoadedMessages) != 0 {
|
||||
t.Error("Empty LoadedMessages slice should remain empty")
|
||||
}
|
||||
|
||||
if len(copied.Images) != 0 {
|
||||
t.Error("Empty Images slice should remain empty")
|
||||
}
|
||||
@@ -1987,16 +2043,20 @@ func TestRunOptions_Copy_Independence(t *testing.T) {
|
||||
// Test that modifications to original don't affect copy
|
||||
originalThink := &api.ThinkValue{Value: "original"}
|
||||
original := runOptions{
|
||||
Model: "original-model",
|
||||
Messages: []api.Message{{Role: "user", Content: "original"}},
|
||||
Options: map[string]any{"key": "value"},
|
||||
Think: originalThink,
|
||||
Model: "original-model",
|
||||
LoadedMessages: []api.Message{{Role: "assistant", Content: "loaded"}},
|
||||
Messages: []api.Message{{Role: "user", Content: "original"}},
|
||||
Options: map[string]any{"key": "value"},
|
||||
Think: originalThink,
|
||||
}
|
||||
|
||||
copied := original.Copy()
|
||||
|
||||
// Modify original
|
||||
original.Model = "modified-model"
|
||||
if len(original.LoadedMessages) > 0 {
|
||||
original.LoadedMessages[0].Content = "modified loaded"
|
||||
}
|
||||
if len(original.Messages) > 0 {
|
||||
original.Messages[0].Content = "modified"
|
||||
}
|
||||
@@ -2010,6 +2070,10 @@ func TestRunOptions_Copy_Independence(t *testing.T) {
|
||||
t.Error("Copy Model should not be affected by original modification")
|
||||
}
|
||||
|
||||
if len(copied.LoadedMessages) > 0 && copied.LoadedMessages[0].Content == "modified loaded" {
|
||||
t.Error("Copy LoadedMessages should not be affected by original modification")
|
||||
}
|
||||
|
||||
if len(copied.Messages) > 0 && copied.Messages[0].Content == "modified" {
|
||||
t.Error("Copy Messages should not be affected by original modification")
|
||||
}
|
||||
|
||||
+17
-3
@@ -214,10 +214,17 @@ func generateInteractive(cmd *cobra.Command, opts runOptions) error {
|
||||
}
|
||||
origOpts := opts.Copy()
|
||||
|
||||
client, err := api.ClientFromEnvironment()
|
||||
if err != nil {
|
||||
fmt.Println("error: couldn't connect to ollama server")
|
||||
return err
|
||||
}
|
||||
|
||||
opts.Model = args[1]
|
||||
opts.Messages = []api.Message{}
|
||||
opts.LoadedMessages = nil
|
||||
fmt.Printf("Loading model '%s'\n", opts.Model)
|
||||
opts.Think, err = inferThinkingOption(nil, &opts, thinkExplicitlySet)
|
||||
info, err := client.Show(cmd.Context(), &api.ShowRequest{Model: opts.Model})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
fmt.Printf("Couldn't find model '%s'\n", opts.Model)
|
||||
@@ -226,6 +233,11 @@ func generateInteractive(cmd *cobra.Command, opts runOptions) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
applyShowResponseToRunOptions(&opts, info)
|
||||
opts.Think, err = inferThinkingOption(&info.Capabilities, &opts, thinkExplicitlySet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := loadOrUnloadModel(cmd, &opts); err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
fmt.Printf("Couldn't find model '%s'\n", opts.Model)
|
||||
@@ -561,8 +573,10 @@ func NewCreateRequest(name string, opts runOptions) *api.CreateRequest {
|
||||
req.Parameters = opts.Options
|
||||
}
|
||||
|
||||
if len(opts.Messages) > 0 {
|
||||
req.Messages = opts.Messages
|
||||
messages := slices.Clone(opts.LoadedMessages)
|
||||
messages = append(messages, opts.Messages...)
|
||||
if len(messages) > 0 {
|
||||
req.Messages = messages
|
||||
}
|
||||
|
||||
return req
|
||||
|
||||
@@ -58,6 +58,9 @@ func TestLaunchCmd(t *testing.T) {
|
||||
if cmd.Long == "" {
|
||||
t.Error("Long description should not be empty")
|
||||
}
|
||||
if !strings.Contains(cmd.Long, "hermes") {
|
||||
t.Error("Long description should mention hermes")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("flags exist", func(t *testing.T) {
|
||||
@@ -347,7 +350,7 @@ func TestLaunchCmdYes_AutoConfirmsLaunchPromptPath(t *testing.T) {
|
||||
restore := OverrideIntegration("stubeditor", stub)
|
||||
defer restore()
|
||||
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
t.Fatalf("unexpected prompt with --yes: %q", prompt)
|
||||
return false, nil
|
||||
}
|
||||
@@ -393,7 +396,7 @@ func TestLaunchCmdHeadlessWithYes_AutoPullsMissingLocalModel(t *testing.T) {
|
||||
restore := OverrideIntegration("stubapp", stub)
|
||||
defer restore()
|
||||
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
t.Fatalf("unexpected prompt with --yes in headless autopull path: %q", prompt)
|
||||
return false, nil
|
||||
}
|
||||
@@ -436,7 +439,7 @@ func TestLaunchCmdHeadlessWithoutYes_ReturnsActionableConfirmError(t *testing.T)
|
||||
restore := OverrideIntegration("stubeditor", stub)
|
||||
defer restore()
|
||||
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
t.Fatalf("unexpected prompt in headless non-yes mode: %q", prompt)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
)
|
||||
|
||||
// Copilot implements Runner for GitHub Copilot CLI integration.
|
||||
type Copilot struct{}
|
||||
|
||||
func (c *Copilot) String() string { return "Copilot CLI" }
|
||||
|
||||
func (c *Copilot) args(model string, extra []string) []string {
|
||||
var args []string
|
||||
if model != "" {
|
||||
args = append(args, "--model", model)
|
||||
}
|
||||
args = append(args, extra...)
|
||||
return args
|
||||
}
|
||||
|
||||
func (c *Copilot) findPath() (string, error) {
|
||||
if p, err := exec.LookPath("copilot"); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
name := "copilot"
|
||||
if runtime.GOOS == "windows" {
|
||||
name = "copilot.exe"
|
||||
}
|
||||
fallback := filepath.Join(home, ".local", "bin", name)
|
||||
if _, err := os.Stat(fallback); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
func (c *Copilot) Run(model string, args []string) error {
|
||||
copilotPath, err := c.findPath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("copilot is not installed, install from https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli")
|
||||
}
|
||||
|
||||
cmd := exec.Command(copilotPath, c.args(model, args)...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
cmd.Env = append(os.Environ(), c.envVars(model)...)
|
||||
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// envVars returns the environment variables that configure Copilot CLI
|
||||
// to use Ollama as its model provider.
|
||||
func (c *Copilot) envVars(model string) []string {
|
||||
env := []string{
|
||||
"COPILOT_PROVIDER_BASE_URL=" + envconfig.Host().String() + "/v1",
|
||||
"COPILOT_PROVIDER_API_KEY=",
|
||||
"COPILOT_PROVIDER_WIRE_API=responses",
|
||||
}
|
||||
|
||||
if model != "" {
|
||||
env = append(env, "COPILOT_MODEL="+model)
|
||||
}
|
||||
|
||||
return env
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCopilotIntegration(t *testing.T) {
|
||||
c := &Copilot{}
|
||||
|
||||
t.Run("String", func(t *testing.T) {
|
||||
if got := c.String(); got != "Copilot CLI" {
|
||||
t.Errorf("String() = %q, want %q", got, "Copilot CLI")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("implements Runner", func(t *testing.T) {
|
||||
var _ Runner = c
|
||||
})
|
||||
}
|
||||
|
||||
func TestCopilotFindPath(t *testing.T) {
|
||||
c := &Copilot{}
|
||||
|
||||
t.Run("finds copilot in PATH", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
name := "copilot"
|
||||
if runtime.GOOS == "windows" {
|
||||
name = "copilot.exe"
|
||||
}
|
||||
fakeBin := filepath.Join(tmpDir, name)
|
||||
os.WriteFile(fakeBin, []byte("#!/bin/sh\n"), 0o755)
|
||||
t.Setenv("PATH", tmpDir)
|
||||
|
||||
got, err := c.findPath()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != fakeBin {
|
||||
t.Errorf("findPath() = %q, want %q", got, fakeBin)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns error when not in PATH", func(t *testing.T) {
|
||||
t.Setenv("PATH", t.TempDir()) // empty dir, no copilot binary
|
||||
|
||||
_, err := c.findPath()
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("falls back to ~/.local/bin/copilot", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
t.Setenv("PATH", t.TempDir()) // empty dir, no copilot binary
|
||||
|
||||
name := "copilot"
|
||||
if runtime.GOOS == "windows" {
|
||||
name = "copilot.exe"
|
||||
}
|
||||
fallback := filepath.Join(tmpDir, ".local", "bin", name)
|
||||
os.MkdirAll(filepath.Dir(fallback), 0o755)
|
||||
os.WriteFile(fallback, []byte("#!/bin/sh\n"), 0o755)
|
||||
|
||||
got, err := c.findPath()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != fallback {
|
||||
t.Errorf("findPath() = %q, want %q", got, fallback)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns error when neither PATH nor fallback exists", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
t.Setenv("PATH", t.TempDir()) // empty dir, no copilot binary
|
||||
|
||||
_, err := c.findPath()
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCopilotArgs(t *testing.T) {
|
||||
c := &Copilot{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{"with model", "llama3.2", nil, []string{"--model", "llama3.2"}},
|
||||
{"empty model", "", nil, nil},
|
||||
{"with model and extra", "llama3.2", []string{"--verbose"}, []string{"--model", "llama3.2", "--verbose"}},
|
||||
{"empty model with help", "", []string{"--help"}, []string{"--help"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := c.args(tt.model, tt.args)
|
||||
if !slices.Equal(got, tt.want) {
|
||||
t.Errorf("args(%q, %v) = %v, want %v", tt.model, tt.args, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopilotEnvVars(t *testing.T) {
|
||||
c := &Copilot{}
|
||||
|
||||
envMap := func(envs []string) map[string]string {
|
||||
m := make(map[string]string)
|
||||
for _, e := range envs {
|
||||
k, v, _ := strings.Cut(e, "=")
|
||||
m[k] = v
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
t.Run("sets required provider env vars with model", func(t *testing.T) {
|
||||
got := envMap(c.envVars("llama3.2"))
|
||||
if got["COPILOT_PROVIDER_BASE_URL"] == "" {
|
||||
t.Error("COPILOT_PROVIDER_BASE_URL should be set")
|
||||
}
|
||||
if !strings.HasSuffix(got["COPILOT_PROVIDER_BASE_URL"], "/v1") {
|
||||
t.Errorf("COPILOT_PROVIDER_BASE_URL = %q, want /v1 suffix", got["COPILOT_PROVIDER_BASE_URL"])
|
||||
}
|
||||
if _, ok := got["COPILOT_PROVIDER_API_KEY"]; !ok {
|
||||
t.Error("COPILOT_PROVIDER_API_KEY should be set (empty)")
|
||||
}
|
||||
if got["COPILOT_PROVIDER_WIRE_API"] != "responses" {
|
||||
t.Errorf("COPILOT_PROVIDER_WIRE_API = %q, want %q", got["COPILOT_PROVIDER_WIRE_API"], "responses")
|
||||
}
|
||||
if got["COPILOT_MODEL"] != "llama3.2" {
|
||||
t.Errorf("COPILOT_MODEL = %q, want %q", got["COPILOT_MODEL"], "llama3.2")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("omits COPILOT_MODEL when model is empty", func(t *testing.T) {
|
||||
got := envMap(c.envVars(""))
|
||||
if _, ok := got["COPILOT_MODEL"]; ok {
|
||||
t.Errorf("COPILOT_MODEL should not be set for empty model, got %q", got["COPILOT_MODEL"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uses custom OLLAMA_HOST", func(t *testing.T) {
|
||||
t.Setenv("OLLAMA_HOST", "http://myhost:9999")
|
||||
got := envMap(c.envVars("test"))
|
||||
if !strings.Contains(got["COPILOT_PROVIDER_BASE_URL"], "myhost:9999") {
|
||||
t.Errorf("COPILOT_PROVIDER_BASE_URL = %q, want custom host", got["COPILOT_PROVIDER_BASE_URL"])
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,962 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
pathpkg "path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/cmd/config"
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
)
|
||||
|
||||
const (
|
||||
hermesInstallScript = "curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --skip-setup"
|
||||
hermesProviderName = "Ollama"
|
||||
hermesProviderKey = "ollama-launch"
|
||||
hermesLegacyKey = "ollama"
|
||||
hermesPlaceholderKey = "ollama"
|
||||
hermesGatewaySetupHint = "hermes gateway setup"
|
||||
hermesGatewaySetupTitle = "Connect a messaging app now?"
|
||||
)
|
||||
|
||||
var (
|
||||
hermesGOOS = runtime.GOOS
|
||||
hermesLookPath = exec.LookPath
|
||||
hermesCommand = exec.Command
|
||||
hermesUserHome = os.UserHomeDir
|
||||
hermesOllamaURL = envconfig.ConnectableHost
|
||||
)
|
||||
|
||||
var hermesMessagingEnvGroups = [][]string{
|
||||
{"TELEGRAM_BOT_TOKEN"},
|
||||
{"DISCORD_BOT_TOKEN"},
|
||||
{"SLACK_BOT_TOKEN"},
|
||||
{"SIGNAL_ACCOUNT"},
|
||||
{"EMAIL_ADDRESS"},
|
||||
{"TWILIO_ACCOUNT_SID"},
|
||||
{"MATRIX_ACCESS_TOKEN", "MATRIX_PASSWORD"},
|
||||
{"MATTERMOST_TOKEN"},
|
||||
{"WHATSAPP_PHONE_NUMBER_ID"},
|
||||
{"DINGTALK_CLIENT_ID"},
|
||||
{"FEISHU_APP_ID"},
|
||||
{"WECOM_BOT_ID"},
|
||||
{"WEIXIN_ACCOUNT_ID"},
|
||||
{"BLUEBUBBLES_SERVER_URL"},
|
||||
{"WEBHOOK_ENABLED"},
|
||||
}
|
||||
|
||||
// Hermes is intentionally not an Editor integration: launch owns one primary
|
||||
// model and the local Ollama endpoint, while Hermes keeps its own discovery and
|
||||
// switching UX after startup.
|
||||
type Hermes struct{}
|
||||
|
||||
type hermesConfigBackend struct {
|
||||
displayPath string
|
||||
read func() ([]byte, error)
|
||||
write func([]byte) error
|
||||
}
|
||||
|
||||
func (h *Hermes) String() string { return "Hermes Agent" }
|
||||
|
||||
func (h *Hermes) Run(_ string, args []string) error {
|
||||
// Hermes reads its primary model from config.yaml. launch configures that
|
||||
// default model ahead of time so we can keep runtime invocation simple and
|
||||
// still let Hermes discover additional models later via its own UX.
|
||||
if hermesGOOS == "windows" {
|
||||
return h.runWindows(args)
|
||||
}
|
||||
|
||||
bin, err := h.findUnixBinary()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.runGatewaySetupPreflight(args, func() error {
|
||||
return hermesAttachedCommand(bin, "gateway", "setup").Run()
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return hermesAttachedCommand(bin, args...).Run()
|
||||
}
|
||||
|
||||
func (h *Hermes) Paths() []string {
|
||||
backend, err := h.configBackend()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return []string{backend.displayPath}
|
||||
}
|
||||
|
||||
func (h *Hermes) Configure(model string) error {
|
||||
backend, err := h.configBackend()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg := map[string]any{}
|
||||
if data, err := backend.read(); err == nil {
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return fmt.Errorf("parse hermes config: %w", err)
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
modelSection, _ := cfg["model"].(map[string]any)
|
||||
if modelSection == nil {
|
||||
modelSection = make(map[string]any)
|
||||
}
|
||||
models := h.listModels(model)
|
||||
applyHermesManagedProviders(cfg, hermesBaseURL(), model, models)
|
||||
|
||||
// launch writes the minimum provider/default-model settings needed to
|
||||
// bootstrap Hermes against Ollama. The active provider stays on a
|
||||
// launch-owned key so /model stays aligned with the launcher-managed entry,
|
||||
// and the Ollama endpoint lives in providers: so the picker shows one row.
|
||||
modelSection["provider"] = hermesProviderKey
|
||||
modelSection["default"] = model
|
||||
modelSection["base_url"] = hermesBaseURL()
|
||||
modelSection["api_key"] = hermesPlaceholderKey
|
||||
cfg["model"] = modelSection
|
||||
|
||||
// use Hermes' built-in web toolset for now.
|
||||
// TODO(parthsareen): move this to using Ollama web search
|
||||
cfg["toolsets"] = mergeHermesToolsets(cfg["toolsets"])
|
||||
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return backend.write(data)
|
||||
}
|
||||
|
||||
func (h *Hermes) CurrentModel() string {
|
||||
backend, err := h.configBackend()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
data, err := backend.read()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
cfg := map[string]any{}
|
||||
if yaml.Unmarshal(data, &cfg) != nil {
|
||||
return ""
|
||||
}
|
||||
return hermesManagedCurrentModel(cfg, hermesBaseURL())
|
||||
}
|
||||
|
||||
func (h *Hermes) Onboard() error {
|
||||
return config.MarkIntegrationOnboarded("hermes")
|
||||
}
|
||||
|
||||
func (h *Hermes) RequiresInteractiveOnboarding() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *Hermes) RefreshRuntimeAfterConfigure() error {
|
||||
running, err := h.gatewayRunning()
|
||||
if err != nil {
|
||||
return fmt.Errorf("check Hermes gateway status: %w", err)
|
||||
}
|
||||
if !running {
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%sRefreshing Hermes messaging gateway...%s\n", ansiGray, ansiReset)
|
||||
if err := h.restartGateway(); err != nil {
|
||||
return fmt.Errorf("restart Hermes gateway: %w", err)
|
||||
}
|
||||
fmt.Fprintln(os.Stderr)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Hermes) installed() bool {
|
||||
if hermesGOOS == "windows" {
|
||||
if _, err := hermesLookPath("hermes"); err == nil {
|
||||
return true
|
||||
}
|
||||
return h.wslHasHermes()
|
||||
}
|
||||
|
||||
_, err := h.findUnixBinary()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (h *Hermes) ensureInstalled() error {
|
||||
if h.installed() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if hermesGOOS == "windows" {
|
||||
return h.ensureInstalledWindows()
|
||||
}
|
||||
|
||||
var missing []string
|
||||
for _, dep := range []string{"bash", "curl", "git"} {
|
||||
if _, err := hermesLookPath(dep); err != nil {
|
||||
missing = append(missing, dep)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("Hermes is not installed and required dependencies are missing\n\nInstall the following first:\n %s\n\nThen re-run:\n ollama launch hermes", strings.Join(missing, "\n "))
|
||||
}
|
||||
|
||||
ok, err := ConfirmPrompt("Hermes is not installed. Install now?")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("hermes installation cancelled")
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\nInstalling Hermes...\n")
|
||||
if err := hermesAttachedCommand("bash", "-lc", hermesInstallScript).Run(); err != nil {
|
||||
return fmt.Errorf("failed to install hermes: %w", err)
|
||||
}
|
||||
|
||||
if !h.installed() {
|
||||
return fmt.Errorf("hermes was installed but the binary was not found on PATH\n\nYou may need to restart your shell")
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%sHermes installed successfully%s\n\n", ansiGreen, ansiReset)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Hermes) ensureInstalledWindows() error {
|
||||
// Hermes upstream support is WSL-oriented, so Windows launch uses a hybrid
|
||||
// WSL handoff that stays on the same install path as upstream Hermes.
|
||||
if _, err := hermesLookPath("hermes"); err == nil {
|
||||
return nil
|
||||
}
|
||||
if !h.wslAvailable() {
|
||||
return hermesWindowsHint(fmt.Errorf("hermes is not installed"))
|
||||
}
|
||||
if h.wslHasHermes() {
|
||||
return nil
|
||||
}
|
||||
|
||||
ok, err := ConfirmPromptWithOptions("Hermes runs through WSL2 on Windows. Install it in WSL now?", ConfirmOptions{
|
||||
YesLabel: "Use WSL",
|
||||
NoLabel: "Show manual steps",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return hermesWindowsHint(fmt.Errorf("hermes is not installed"))
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\nInstalling Hermes in WSL...\n")
|
||||
if err := h.runWSL("bash", "-lc", hermesInstallScript); err != nil {
|
||||
return hermesWindowsHint(fmt.Errorf("failed to install hermes in WSL: %w", err))
|
||||
}
|
||||
if !h.wslHasHermes() {
|
||||
return hermesWindowsHint(fmt.Errorf("hermes install finished but the WSL binary was not found"))
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%sHermes installed successfully in WSL%s\n\n", ansiGreen, ansiReset)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Hermes) listModels(defaultModel string) []string {
|
||||
client := hermesOllamaClient()
|
||||
resp, err := client.List(context.Background())
|
||||
if err != nil {
|
||||
return []string{defaultModel}
|
||||
}
|
||||
|
||||
models := make([]string, 0, len(resp.Models)+1)
|
||||
seen := make(map[string]struct{}, len(resp.Models)+1)
|
||||
add := func(name string) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[name]; ok {
|
||||
return
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
models = append(models, name)
|
||||
}
|
||||
|
||||
add(defaultModel)
|
||||
for _, entry := range resp.Models {
|
||||
add(entry.Name)
|
||||
}
|
||||
if len(models) == 0 {
|
||||
return []string{defaultModel}
|
||||
}
|
||||
return models
|
||||
}
|
||||
|
||||
func (h *Hermes) findUnixBinary() (string, error) {
|
||||
if path, err := hermesLookPath("hermes"); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
home, err := hermesUserHome()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fallback := filepath.Join(home, ".local", "bin", "hermes")
|
||||
if _, err := os.Stat(fallback); err == nil {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("hermes is not installed")
|
||||
}
|
||||
|
||||
func (h *Hermes) runWindows(args []string) error {
|
||||
if path, err := hermesLookPath("hermes"); err == nil {
|
||||
if err := h.runGatewaySetupPreflight(args, func() error {
|
||||
return hermesAttachedCommand(path, "gateway", "setup").Run()
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return hermesAttachedCommand(path, args...).Run()
|
||||
}
|
||||
if !h.wslAvailable() {
|
||||
return hermesWindowsHint(fmt.Errorf("hermes is not installed"))
|
||||
}
|
||||
if err := h.runGatewaySetupPreflight(args, func() error {
|
||||
return h.runWSL("hermes", "gateway", "setup")
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.runWSL(append([]string{"hermes"}, args...)...); err != nil {
|
||||
return hermesWindowsHint(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Hermes) runWSL(args ...string) error {
|
||||
if !h.wslAvailable() {
|
||||
return fmt.Errorf("wsl.exe is not available")
|
||||
}
|
||||
|
||||
return hermesAttachedCommand("wsl.exe", "bash", "-lc", shellQuoteArgs(args)).Run()
|
||||
}
|
||||
|
||||
func (h *Hermes) runWSLCombinedOutput(args ...string) ([]byte, error) {
|
||||
if !h.wslAvailable() {
|
||||
return nil, fmt.Errorf("wsl.exe is not available")
|
||||
}
|
||||
|
||||
return hermesCommand("wsl.exe", "bash", "-lc", shellQuoteArgs(args)).CombinedOutput()
|
||||
}
|
||||
|
||||
func (h *Hermes) wslAvailable() bool {
|
||||
_, err := hermesLookPath("wsl.exe")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (h *Hermes) wslHasHermes() bool {
|
||||
if !h.wslAvailable() {
|
||||
return false
|
||||
}
|
||||
cmd := hermesCommand("wsl.exe", "bash", "-lc", "command -v hermes >/dev/null 2>&1")
|
||||
return cmd.Run() == nil
|
||||
}
|
||||
|
||||
func (h *Hermes) configBackend() (*hermesConfigBackend, error) {
|
||||
if hermesGOOS == "windows" {
|
||||
if _, err := hermesLookPath("hermes"); err == nil {
|
||||
return hermesLocalConfigBackend()
|
||||
}
|
||||
if h.wslAvailable() {
|
||||
return h.wslConfigBackend()
|
||||
}
|
||||
}
|
||||
return hermesLocalConfigBackend()
|
||||
}
|
||||
|
||||
func hermesConfigPath() (string, error) {
|
||||
home, err := hermesUserHome()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".hermes", "config.yaml"), nil
|
||||
}
|
||||
|
||||
func hermesLocalConfigBackend() (*hermesConfigBackend, error) {
|
||||
configPath, err := hermesConfigPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &hermesConfigBackend{
|
||||
displayPath: configPath,
|
||||
read: func() ([]byte, error) {
|
||||
return os.ReadFile(configPath)
|
||||
},
|
||||
write: func(data []byte) error {
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutil.WriteWithBackup(configPath, data)
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Hermes) wslConfigBackend() (*hermesConfigBackend, error) {
|
||||
home, err := h.wslHome()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
configPath := pathpkg.Join(home, ".hermes", "config.yaml")
|
||||
return &hermesConfigBackend{
|
||||
displayPath: configPath,
|
||||
read: func() ([]byte, error) {
|
||||
return h.readWSLFile(configPath)
|
||||
},
|
||||
write: func(data []byte) error {
|
||||
return h.writeWSLConfig(configPath, data)
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Hermes) wslHome() (string, error) {
|
||||
if !h.wslAvailable() {
|
||||
return "", fmt.Errorf("wsl.exe is not available")
|
||||
}
|
||||
cmd := hermesCommand("wsl.exe", "bash", "-lc", `printf %s "$HOME"`)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
home := strings.TrimSpace(string(out))
|
||||
if home == "" {
|
||||
return "", fmt.Errorf("could not resolve WSL home directory")
|
||||
}
|
||||
return home, nil
|
||||
}
|
||||
|
||||
func (h *Hermes) readWSLFile(path string) ([]byte, error) {
|
||||
pathArg := shellQuoteArgs([]string{path})
|
||||
cmd := hermesCommand("wsl.exe", "bash", "-lc", fmt.Sprintf("if [ -f %s ]; then cat %s; else exit 42; fi", pathArg, pathArg))
|
||||
out, err := cmd.Output()
|
||||
if err == nil {
|
||||
return out, nil
|
||||
}
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.ExitCode() == 42 {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (h *Hermes) writeWSLConfig(path string, data []byte) error {
|
||||
if existing, err := h.readWSLFile(path); err == nil {
|
||||
if !bytes.Equal(existing, data) {
|
||||
if err := hermesBackupData(path, existing); err != nil {
|
||||
return fmt.Errorf("backup failed: %w", err)
|
||||
}
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("read existing file: %w", err)
|
||||
}
|
||||
|
||||
dir := pathpkg.Dir(path)
|
||||
dirArg := shellQuoteArgs([]string{dir})
|
||||
pathArg := shellQuoteArgs([]string{path})
|
||||
script := fmt.Sprintf(
|
||||
"dir=%s; path=%s; mkdir -p \"$dir\" && tmp=$(mktemp \"$dir/.tmp-XXXXXX\") && cat > \"$tmp\" && mv \"$tmp\" \"$path\"",
|
||||
dirArg,
|
||||
pathArg,
|
||||
)
|
||||
cmd := hermesCommand("wsl.exe", "bash", "-lc", script)
|
||||
cmd.Stdin = bytes.NewReader(data)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
if msg := strings.TrimSpace(string(out)); msg != "" {
|
||||
return fmt.Errorf("%w: %s", err, msg)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hermesBackupData(path string, data []byte) error {
|
||||
if err := os.MkdirAll(fileutil.BackupDir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
backupPath := filepath.Join(fileutil.BackupDir(), fmt.Sprintf("%s.%d", filepath.Base(path), time.Now().Unix()))
|
||||
return os.WriteFile(backupPath, data, 0o644)
|
||||
}
|
||||
|
||||
func hermesBaseURL() string {
|
||||
return strings.TrimRight(hermesOllamaURL().String(), "/") + "/v1"
|
||||
}
|
||||
|
||||
func hermesEnvPath() (string, error) {
|
||||
home, err := hermesUserHome()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".hermes", ".env"), nil
|
||||
}
|
||||
|
||||
func (h *Hermes) runGatewaySetupPreflight(args []string, runSetup func() error) error {
|
||||
if len(args) > 0 || !isInteractiveSession() || currentLaunchConfirmPolicy.yes || currentLaunchConfirmPolicy.requireYesMessage {
|
||||
return nil
|
||||
}
|
||||
if h.messagingConfigured() {
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\nHermes can message you on Telegram, Discord, Slack, and more.\n\n")
|
||||
ok, err := ConfirmPromptWithOptions(hermesGatewaySetupTitle, ConfirmOptions{
|
||||
YesLabel: "Yes",
|
||||
NoLabel: "Set up later",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if err := runSetup(); err != nil {
|
||||
return fmt.Errorf("hermes messaging setup failed: %w\n\nTry running: %s", err, hermesGatewaySetupHint)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Hermes) messagingConfigured() bool {
|
||||
envVars, err := h.gatewayEnvVars()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, group := range hermesMessagingEnvGroups {
|
||||
for _, key := range group {
|
||||
if strings.TrimSpace(envVars[key]) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *Hermes) gatewayEnvVars() (map[string]string, error) {
|
||||
envVars := make(map[string]string)
|
||||
|
||||
data, err := h.readGatewayEnvFile()
|
||||
switch {
|
||||
case err == nil:
|
||||
for key, value := range hermesParseEnvFile(data) {
|
||||
envVars[key] = value
|
||||
}
|
||||
case os.IsNotExist(err):
|
||||
// nothing persisted yet
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if h.usesLocalRuntimeEnv() {
|
||||
for _, group := range hermesMessagingEnvGroups {
|
||||
for _, key := range group {
|
||||
if value, ok := os.LookupEnv(key); ok {
|
||||
envVars[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return envVars, nil
|
||||
}
|
||||
|
||||
func (h *Hermes) readGatewayEnvFile() ([]byte, error) {
|
||||
if hermesGOOS == "windows" {
|
||||
if _, err := hermesLookPath("hermes"); err == nil {
|
||||
path, err := hermesEnvPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
if h.wslAvailable() {
|
||||
home, err := h.wslHome()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return h.readWSLFile(pathpkg.Join(home, ".hermes", ".env"))
|
||||
}
|
||||
}
|
||||
|
||||
path, err := hermesEnvPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
|
||||
func (h *Hermes) usesLocalRuntimeEnv() bool {
|
||||
if hermesGOOS != "windows" {
|
||||
return true
|
||||
}
|
||||
_, err := hermesLookPath("hermes")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (h *Hermes) gatewayRunning() (bool, error) {
|
||||
status, err := h.gatewayStatusOutput()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return hermesGatewayStatusRunning(status), nil
|
||||
}
|
||||
|
||||
func (h *Hermes) gatewayStatusOutput() (string, error) {
|
||||
if hermesGOOS == "windows" {
|
||||
if path, err := hermesLookPath("hermes"); err == nil {
|
||||
out, err := hermesCommand(path, "gateway", "status").CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
if !h.wslAvailable() {
|
||||
return "", hermesWindowsHint(fmt.Errorf("hermes is not installed"))
|
||||
}
|
||||
out, err := h.runWSLCombinedOutput("hermes", "gateway", "status")
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
bin, err := h.findUnixBinary()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out, err := hermesCommand(bin, "gateway", "status").CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
func (h *Hermes) restartGateway() error {
|
||||
if hermesGOOS == "windows" {
|
||||
if path, err := hermesLookPath("hermes"); err == nil {
|
||||
return hermesAttachedCommand(path, "gateway", "restart").Run()
|
||||
}
|
||||
if !h.wslAvailable() {
|
||||
return hermesWindowsHint(fmt.Errorf("hermes is not installed"))
|
||||
}
|
||||
if err := h.runWSL("hermes", "gateway", "restart"); err != nil {
|
||||
return hermesWindowsHint(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
bin, err := h.findUnixBinary()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return hermesAttachedCommand(bin, "gateway", "restart").Run()
|
||||
}
|
||||
|
||||
func hermesGatewayStatusRunning(output string) bool {
|
||||
status := strings.ToLower(output)
|
||||
switch {
|
||||
case strings.Contains(status, "gateway is not running"):
|
||||
return false
|
||||
case strings.Contains(status, "gateway service is stopped"):
|
||||
return false
|
||||
case strings.Contains(status, "gateway service is not loaded"):
|
||||
return false
|
||||
case strings.Contains(status, "gateway is running"):
|
||||
return true
|
||||
case strings.Contains(status, "gateway service is running"):
|
||||
return true
|
||||
case strings.Contains(status, "gateway service is loaded"):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func hermesParseEnvFile(data []byte) map[string]string {
|
||||
out := make(map[string]string)
|
||||
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(strings.TrimPrefix(scanner.Text(), "\ufeff"))
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "export ") {
|
||||
line = strings.TrimSpace(strings.TrimPrefix(line, "export "))
|
||||
}
|
||||
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) >= 2 {
|
||||
switch {
|
||||
case value[0] == '"' && value[len(value)-1] == '"':
|
||||
if unquoted, err := strconv.Unquote(value); err == nil {
|
||||
value = unquoted
|
||||
}
|
||||
case value[0] == '\'' && value[len(value)-1] == '\'':
|
||||
value = value[1 : len(value)-1]
|
||||
}
|
||||
}
|
||||
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hermesOllamaClient() *api.Client {
|
||||
// Hermes queries the same launch-resolved Ollama host that launch writes
|
||||
// into config, so model discovery follows the configured endpoint.
|
||||
return api.NewClient(hermesOllamaURL(), http.DefaultClient)
|
||||
}
|
||||
|
||||
func applyHermesManagedProviders(cfg map[string]any, baseURL string, model string, models []string) {
|
||||
providers := hermesUserProviders(cfg["providers"])
|
||||
entry := hermesManagedProviderEntry(providers)
|
||||
if entry == nil {
|
||||
entry = make(map[string]any)
|
||||
}
|
||||
entry["name"] = hermesProviderName
|
||||
entry["api"] = baseURL
|
||||
entry["default_model"] = model
|
||||
entry["models"] = hermesStringListAny(models)
|
||||
providers[hermesProviderKey] = entry
|
||||
delete(providers, hermesLegacyKey)
|
||||
cfg["providers"] = providers
|
||||
|
||||
customProviders := hermesWithoutManagedCustomProviders(cfg["custom_providers"])
|
||||
if len(customProviders) == 0 {
|
||||
delete(cfg, "custom_providers")
|
||||
return
|
||||
}
|
||||
cfg["custom_providers"] = customProviders
|
||||
}
|
||||
|
||||
func hermesManagedCurrentModel(cfg map[string]any, baseURL string) string {
|
||||
modelCfg, _ := cfg["model"].(map[string]any)
|
||||
if modelCfg == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
provider, _ := modelCfg["provider"].(string)
|
||||
if strings.TrimSpace(strings.ToLower(provider)) != hermesProviderKey {
|
||||
return ""
|
||||
}
|
||||
|
||||
configBaseURL, _ := modelCfg["base_url"].(string)
|
||||
if hermesNormalizeURL(configBaseURL) != hermesNormalizeURL(baseURL) {
|
||||
return ""
|
||||
}
|
||||
|
||||
current, _ := modelCfg["default"].(string)
|
||||
current = strings.TrimSpace(current)
|
||||
if current == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
providers := hermesUserProviders(cfg["providers"])
|
||||
entry, _ := providers[hermesProviderKey].(map[string]any)
|
||||
if entry == nil {
|
||||
return ""
|
||||
}
|
||||
if hermesHasManagedCustomProvider(cfg["custom_providers"]) {
|
||||
return ""
|
||||
}
|
||||
|
||||
apiURL, _ := entry["api"].(string)
|
||||
if hermesNormalizeURL(apiURL) != hermesNormalizeURL(baseURL) {
|
||||
return ""
|
||||
}
|
||||
|
||||
defaultModel, _ := entry["default_model"].(string)
|
||||
if strings.TrimSpace(defaultModel) != current {
|
||||
return ""
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
func hermesUserProviders(current any) map[string]any {
|
||||
switch existing := current.(type) {
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(existing))
|
||||
for key, value := range existing {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
case map[any]any:
|
||||
out := make(map[string]any, len(existing))
|
||||
for key, value := range existing {
|
||||
if s, ok := key.(string); ok {
|
||||
out[s] = value
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return make(map[string]any)
|
||||
}
|
||||
}
|
||||
|
||||
func hermesCustomProviders(current any) []any {
|
||||
switch existing := current.(type) {
|
||||
case []any:
|
||||
return append([]any(nil), existing...)
|
||||
case []map[string]any:
|
||||
out := make([]any, 0, len(existing))
|
||||
for _, entry := range existing {
|
||||
out = append(out, entry)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func hermesManagedProviderEntry(providers map[string]any) map[string]any {
|
||||
for _, key := range []string{hermesProviderKey, hermesLegacyKey} {
|
||||
if entry, _ := providers[key].(map[string]any); entry != nil {
|
||||
return entry
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hermesWithoutManagedCustomProviders(current any) []any {
|
||||
customProviders := hermesCustomProviders(current)
|
||||
preserved := make([]any, 0, len(customProviders))
|
||||
|
||||
for _, item := range customProviders {
|
||||
entry, _ := item.(map[string]any)
|
||||
if entry == nil {
|
||||
preserved = append(preserved, item)
|
||||
continue
|
||||
}
|
||||
if hermesManagedCustomProvider(entry) {
|
||||
continue
|
||||
}
|
||||
preserved = append(preserved, entry)
|
||||
}
|
||||
|
||||
return preserved
|
||||
}
|
||||
|
||||
func hermesHasManagedCustomProvider(current any) bool {
|
||||
for _, item := range hermesCustomProviders(current) {
|
||||
entry, _ := item.(map[string]any)
|
||||
if entry != nil && hermesManagedCustomProvider(entry) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hermesManagedCustomProvider(entry map[string]any) bool {
|
||||
name, _ := entry["name"].(string)
|
||||
return strings.EqualFold(strings.TrimSpace(name), hermesProviderName)
|
||||
}
|
||||
|
||||
func hermesNormalizeURL(raw string) string {
|
||||
return strings.TrimRight(strings.TrimSpace(raw), "/")
|
||||
}
|
||||
|
||||
func hermesStringListAny(models []string) []any {
|
||||
out := make([]any, 0, len(models))
|
||||
for _, model := range dedupeModelList(models) {
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, model)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeHermesToolsets(current any) any {
|
||||
added := false
|
||||
switch existing := current.(type) {
|
||||
case []any:
|
||||
out := make([]any, 0, len(existing)+1)
|
||||
for _, item := range existing {
|
||||
out = append(out, item)
|
||||
if s, _ := item.(string); s == "web" {
|
||||
added = true
|
||||
}
|
||||
}
|
||||
if !added {
|
||||
out = append(out, "web")
|
||||
}
|
||||
return out
|
||||
case []string:
|
||||
out := append([]string(nil), existing...)
|
||||
if !slices.Contains(out, "web") {
|
||||
out = append(out, "web")
|
||||
}
|
||||
asAny := make([]any, 0, len(out))
|
||||
for _, item := range out {
|
||||
asAny = append(asAny, item)
|
||||
}
|
||||
return asAny
|
||||
case string:
|
||||
if strings.TrimSpace(existing) == "" {
|
||||
return []any{"hermes-cli", "web"}
|
||||
}
|
||||
parts := strings.Split(existing, ",")
|
||||
out := make([]any, 0, len(parts)+1)
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if part == "web" {
|
||||
added = true
|
||||
}
|
||||
out = append(out, part)
|
||||
}
|
||||
if !added {
|
||||
out = append(out, "web")
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return []any{"hermes-cli", "web"}
|
||||
}
|
||||
}
|
||||
|
||||
func shellQuoteArgs(args []string) string {
|
||||
quoted := make([]string, 0, len(args))
|
||||
for _, arg := range args {
|
||||
quoted = append(quoted, "'"+strings.ReplaceAll(arg, "'", `'\''`)+"'")
|
||||
}
|
||||
return strings.Join(quoted, " ")
|
||||
}
|
||||
|
||||
func hermesAttachedCommand(name string, args ...string) *exec.Cmd {
|
||||
cmd := hermesCommand(name, args...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd
|
||||
}
|
||||
|
||||
func hermesWindowsHint(err error) error {
|
||||
if hermesGOOS != "windows" {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%w\n\nHermes runs on Windows through WSL2.\nQuick setup: wsl --install\nInstaller docs: https://hermes-agent.nousresearch.com/docs/getting-started/installation/", err)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+178
-81
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/cmd/config"
|
||||
)
|
||||
|
||||
type stubEditorRunner struct {
|
||||
@@ -73,7 +74,7 @@ func TestIntegrationLookup(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIntegrationRegistry(t *testing.T) {
|
||||
expectedIntegrations := []string{"claude", "codex", "droid", "opencode"}
|
||||
expectedIntegrations := []string{"claude", "codex", "droid", "opencode", "hermes"}
|
||||
|
||||
for _, name := range expectedIntegrations {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
@@ -290,7 +291,7 @@ func TestParseArgs(t *testing.T) {
|
||||
func TestIsCloudModel(t *testing.T) {
|
||||
// isCloudModel now only uses Show API, so nil client always returns false
|
||||
t.Run("nil client returns false", func(t *testing.T) {
|
||||
models := []string{"glm-5:cloud", "kimi-k2.5:cloud", "local-model"}
|
||||
models := []string{"glm-5.1:cloud", "kimi-k2.5:cloud", "local-model"}
|
||||
for _, model := range models {
|
||||
if isCloudModel(context.Background(), nil, model) {
|
||||
t.Errorf("isCloudModel(%q) with nil client should return false", model)
|
||||
@@ -310,7 +311,7 @@ func names(items []ModelItem) []string {
|
||||
func TestBuildModelList_NoExistingModels(t *testing.T) {
|
||||
items, _, _, _ := buildModelList(nil, nil, "")
|
||||
|
||||
want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5:cloud", "minimax-m2.7:cloud", "glm-4.7-flash", "qwen3.5"}
|
||||
want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "gemma4", "qwen3.5"}
|
||||
if diff := cmp.Diff(want, names(items)); diff != "" {
|
||||
t.Errorf("with no existing models, items should be recommended in order (-want +got):\n%s", diff)
|
||||
}
|
||||
@@ -328,7 +329,7 @@ func TestBuildModelList_NoExistingModels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildModelList_OnlyLocalModels_CloudRecsAtBottom(t *testing.T) {
|
||||
func TestBuildModelList_OnlyLocalModels_CloudRecsStillFirst(t *testing.T) {
|
||||
existing := []modelInfo{
|
||||
{Name: "llama3.2:latest", Remote: false},
|
||||
{Name: "qwen2.5:latest", Remote: false},
|
||||
@@ -337,24 +338,25 @@ func TestBuildModelList_OnlyLocalModels_CloudRecsAtBottom(t *testing.T) {
|
||||
items, _, _, _ := buildModelList(existing, nil, "")
|
||||
got := names(items)
|
||||
|
||||
// Recommended pinned at top (local recs first, then cloud recs when only-local), then installed non-recs
|
||||
want := []string{"glm-4.7-flash", "qwen3.5", "kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5:cloud", "minimax-m2.7:cloud", "llama3.2", "qwen2.5"}
|
||||
// Cloud recs always come first among recommended, regardless of installed inventory.
|
||||
// Cloud disablement is handled upstream in loadSelectableModels via filterCloudItems.
|
||||
want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "gemma4", "qwen3.5", "llama3.2", "qwen2.5"}
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("recs pinned at top, local recs before cloud recs (-want +got):\n%s", diff)
|
||||
t.Errorf("cloud recs pinned first even when no cloud models installed (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildModelList_BothCloudAndLocal_RegularSort(t *testing.T) {
|
||||
existing := []modelInfo{
|
||||
{Name: "llama3.2:latest", Remote: false},
|
||||
{Name: "glm-5:cloud", Remote: true},
|
||||
{Name: "glm-5.1:cloud", Remote: true},
|
||||
}
|
||||
|
||||
items, _, _, _ := buildModelList(existing, nil, "")
|
||||
got := names(items)
|
||||
|
||||
// All recs pinned at top (cloud before local in mixed case), then non-recs
|
||||
want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5:cloud", "minimax-m2.7:cloud", "glm-4.7-flash", "qwen3.5", "llama3.2"}
|
||||
want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "gemma4", "qwen3.5", "llama3.2"}
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("recs pinned at top, cloud recs first in mixed case (-want +got):\n%s", diff)
|
||||
}
|
||||
@@ -363,7 +365,7 @@ func TestBuildModelList_BothCloudAndLocal_RegularSort(t *testing.T) {
|
||||
func TestBuildModelList_PreCheckedFirst(t *testing.T) {
|
||||
existing := []modelInfo{
|
||||
{Name: "llama3.2:latest", Remote: false},
|
||||
{Name: "glm-5:cloud", Remote: true},
|
||||
{Name: "glm-5.1:cloud", Remote: true},
|
||||
}
|
||||
|
||||
items, _, _, _ := buildModelList(existing, []string{"llama3.2"}, "")
|
||||
@@ -374,17 +376,50 @@ func TestBuildModelList_PreCheckedFirst(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildModelList_CurrentDefaultFirstAmongCheckedNonRec(t *testing.T) {
|
||||
existing := []modelInfo{
|
||||
{Name: "alpha", Remote: false},
|
||||
{Name: "zebra", Remote: false},
|
||||
{Name: "middle", Remote: false},
|
||||
}
|
||||
|
||||
// "zebra" is the current/default; all three are checked, none are recommended.
|
||||
// Expected non-rec order: zebra (default), alpha, middle (alphabetical).
|
||||
items, _, _, _ := buildModelList(existing, []string{"zebra", "alpha", "middle"}, "zebra")
|
||||
got := names(items)
|
||||
|
||||
// Skip recommended items to find the non-rec portion.
|
||||
var nonRec []string
|
||||
for _, item := range items {
|
||||
if !item.Recommended {
|
||||
nonRec = append(nonRec, item.Name)
|
||||
}
|
||||
}
|
||||
if len(nonRec) < 3 {
|
||||
t.Fatalf("expected 3 non-rec items, got %v", nonRec)
|
||||
}
|
||||
if nonRec[0] != "zebra" {
|
||||
t.Errorf("current/default model should be first among checked non-rec, got %v (full: %v)", nonRec, got)
|
||||
}
|
||||
if nonRec[1] != "alpha" {
|
||||
t.Errorf("remaining checked should be alphabetical, expected alpha second, got %v", nonRec)
|
||||
}
|
||||
if nonRec[2] != "middle" {
|
||||
t.Errorf("remaining checked should be alphabetical, expected middle third, got %v", nonRec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildModelList_ExistingRecommendedMarked(t *testing.T) {
|
||||
existing := []modelInfo{
|
||||
{Name: "glm-4.7-flash", Remote: false},
|
||||
{Name: "glm-5:cloud", Remote: true},
|
||||
{Name: "gemma4", Remote: false},
|
||||
{Name: "glm-5.1:cloud", Remote: true},
|
||||
}
|
||||
|
||||
items, _, _, _ := buildModelList(existing, nil, "")
|
||||
|
||||
for _, item := range items {
|
||||
switch item.Name {
|
||||
case "glm-4.7-flash", "glm-5:cloud":
|
||||
case "gemma4", "glm-5.1:cloud":
|
||||
if strings.HasSuffix(item.Description, "(not downloaded)") {
|
||||
t.Errorf("installed recommended %q should not have '(not downloaded)' suffix, got %q", item.Name, item.Description)
|
||||
}
|
||||
@@ -402,17 +437,17 @@ func TestBuildModelList_ExistingRecommendedMarked(t *testing.T) {
|
||||
|
||||
func TestBuildModelList_ExistingCloudModelsNotPushedToBottom(t *testing.T) {
|
||||
existing := []modelInfo{
|
||||
{Name: "glm-4.7-flash", Remote: false},
|
||||
{Name: "glm-5:cloud", Remote: true},
|
||||
{Name: "gemma4", Remote: false},
|
||||
{Name: "glm-5.1:cloud", Remote: true},
|
||||
}
|
||||
|
||||
items, _, _, _ := buildModelList(existing, nil, "")
|
||||
got := names(items)
|
||||
|
||||
// glm-4.7-flash and glm-5:cloud are installed so they sort normally;
|
||||
// gemma4 and glm-5.1:cloud are installed so they sort normally;
|
||||
// kimi-k2.5:cloud, qwen3.5:cloud, and qwen3.5 are not installed so they go to the bottom
|
||||
// All recs: cloud first in mixed case, then local, in rec order within each
|
||||
want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5:cloud", "minimax-m2.7:cloud", "glm-4.7-flash", "qwen3.5"}
|
||||
want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "gemma4", "qwen3.5"}
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("all recs, cloud first in mixed case (-want +got):\n%s", diff)
|
||||
}
|
||||
@@ -430,7 +465,7 @@ func TestBuildModelList_HasRecommendedCloudModel_OnlyNonInstalledAtBottom(t *tes
|
||||
// kimi-k2.5:cloud is installed so it sorts normally;
|
||||
// the rest of the recommendations are not installed so they go to the bottom
|
||||
// All recs pinned at top (cloud first in mixed case), then non-recs
|
||||
want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5:cloud", "minimax-m2.7:cloud", "glm-4.7-flash", "qwen3.5", "llama3.2"}
|
||||
want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "gemma4", "qwen3.5", "llama3.2"}
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("recs pinned at top, cloud first in mixed case (-want +got):\n%s", diff)
|
||||
}
|
||||
@@ -452,7 +487,7 @@ func TestBuildModelList_HasRecommendedCloudModel_OnlyNonInstalledAtBottom(t *tes
|
||||
|
||||
func TestBuildModelList_LatestTagStripped(t *testing.T) {
|
||||
existing := []modelInfo{
|
||||
{Name: "glm-4.7-flash:latest", Remote: false},
|
||||
{Name: "gemma4:latest", Remote: false},
|
||||
{Name: "llama3.2:latest", Remote: false},
|
||||
}
|
||||
|
||||
@@ -466,27 +501,27 @@ func TestBuildModelList_LatestTagStripped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// glm-4.7-flash should not be duplicated (existing :latest matches the recommendation)
|
||||
// gemma4 should not be duplicated (existing :latest matches the recommendation)
|
||||
count := 0
|
||||
for _, name := range got {
|
||||
if name == "glm-4.7-flash" {
|
||||
if name == "gemma4" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("glm-4.7-flash should appear exactly once, got %d in %v", count, got)
|
||||
t.Errorf("gemma4 should appear exactly once, got %d in %v", count, got)
|
||||
}
|
||||
|
||||
// Stripped name should be in existingModels so it won't be pulled
|
||||
if !existingModels["glm-4.7-flash"] {
|
||||
t.Error("glm-4.7-flash should be in existingModels")
|
||||
if !existingModels["gemma4"] {
|
||||
t.Error("gemma4 should be in existingModels")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildModelList_ReturnsExistingAndCloudMaps(t *testing.T) {
|
||||
existing := []modelInfo{
|
||||
{Name: "llama3.2:latest", Remote: false},
|
||||
{Name: "glm-5:cloud", Remote: true},
|
||||
{Name: "glm-5.1:cloud", Remote: true},
|
||||
}
|
||||
|
||||
_, _, existingModels, cloudModels := buildModelList(existing, nil, "")
|
||||
@@ -494,15 +529,15 @@ func TestBuildModelList_ReturnsExistingAndCloudMaps(t *testing.T) {
|
||||
if !existingModels["llama3.2"] {
|
||||
t.Error("llama3.2 should be in existingModels")
|
||||
}
|
||||
if !existingModels["glm-5:cloud"] {
|
||||
t.Error("glm-5:cloud should be in existingModels")
|
||||
if !existingModels["glm-5.1:cloud"] {
|
||||
t.Error("glm-5.1:cloud should be in existingModels")
|
||||
}
|
||||
if existingModels["glm-4.7-flash"] {
|
||||
t.Error("glm-4.7-flash should not be in existingModels (it's a recommendation)")
|
||||
if existingModels["gemma4"] {
|
||||
t.Error("gemma4 should not be in existingModels (it's a recommendation)")
|
||||
}
|
||||
|
||||
if !cloudModels["glm-5:cloud"] {
|
||||
t.Error("glm-5:cloud should be in cloudModels")
|
||||
if !cloudModels["glm-5.1:cloud"] {
|
||||
t.Error("glm-5.1:cloud should be in cloudModels")
|
||||
}
|
||||
if !cloudModels["kimi-k2.5:cloud"] {
|
||||
t.Error("kimi-k2.5:cloud should be in cloudModels (recommended cloud)")
|
||||
@@ -517,7 +552,7 @@ func TestBuildModelList_ReturnsExistingAndCloudMaps(t *testing.T) {
|
||||
|
||||
func TestBuildModelList_RecommendedFieldSet(t *testing.T) {
|
||||
existing := []modelInfo{
|
||||
{Name: "glm-4.7-flash", Remote: false},
|
||||
{Name: "gemma4", Remote: false},
|
||||
{Name: "llama3.2:latest", Remote: false},
|
||||
}
|
||||
|
||||
@@ -525,7 +560,7 @@ func TestBuildModelList_RecommendedFieldSet(t *testing.T) {
|
||||
|
||||
for _, item := range items {
|
||||
switch item.Name {
|
||||
case "glm-4.7-flash", "qwen3.5", "glm-5:cloud", "kimi-k2.5:cloud", "qwen3.5:cloud":
|
||||
case "gemma4", "qwen3.5", "glm-5.1:cloud", "kimi-k2.5:cloud", "qwen3.5:cloud":
|
||||
if !item.Recommended {
|
||||
t.Errorf("%q should have Recommended=true", item.Name)
|
||||
}
|
||||
@@ -540,21 +575,21 @@ func TestBuildModelList_RecommendedFieldSet(t *testing.T) {
|
||||
func TestBuildModelList_MixedCase_CloudRecsFirst(t *testing.T) {
|
||||
existing := []modelInfo{
|
||||
{Name: "llama3.2:latest", Remote: false},
|
||||
{Name: "glm-5:cloud", Remote: true},
|
||||
{Name: "glm-5.1:cloud", Remote: true},
|
||||
}
|
||||
|
||||
items, _, _, _ := buildModelList(existing, nil, "")
|
||||
got := names(items)
|
||||
|
||||
// Cloud recs should sort before local recs in mixed case
|
||||
cloudIdx := slices.Index(got, "glm-5:cloud")
|
||||
localIdx := slices.Index(got, "glm-4.7-flash")
|
||||
cloudIdx := slices.Index(got, "glm-5.1:cloud")
|
||||
localIdx := slices.Index(got, "gemma4")
|
||||
if cloudIdx > localIdx {
|
||||
t.Errorf("cloud recs should be before local recs in mixed case, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildModelList_OnlyLocal_LocalRecsFirst(t *testing.T) {
|
||||
func TestBuildModelList_OnlyLocal_CloudRecsStillFirst(t *testing.T) {
|
||||
existing := []modelInfo{
|
||||
{Name: "llama3.2:latest", Remote: false},
|
||||
}
|
||||
@@ -562,11 +597,11 @@ func TestBuildModelList_OnlyLocal_LocalRecsFirst(t *testing.T) {
|
||||
items, _, _, _ := buildModelList(existing, nil, "")
|
||||
got := names(items)
|
||||
|
||||
// Local recs should sort before cloud recs in only-local case
|
||||
localIdx := slices.Index(got, "glm-4.7-flash")
|
||||
cloudIdx := slices.Index(got, "glm-5:cloud")
|
||||
if localIdx > cloudIdx {
|
||||
t.Errorf("local recs should be before cloud recs in only-local case, got %v", got)
|
||||
// Cloud recs sort before local recs regardless of installed inventory.
|
||||
localIdx := slices.Index(got, "gemma4")
|
||||
cloudIdx := slices.Index(got, "glm-5.1:cloud")
|
||||
if cloudIdx > localIdx {
|
||||
t.Errorf("cloud recs should be before local recs even when only local models installed, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -583,7 +618,7 @@ func TestBuildModelList_RecsAboveNonRecs(t *testing.T) {
|
||||
lastRecIdx := -1
|
||||
firstNonRecIdx := len(got)
|
||||
for i, name := range got {
|
||||
isRec := name == "glm-4.7-flash" || name == "qwen3.5" || name == "minimax-m2.7:cloud" || name == "glm-5:cloud" || name == "kimi-k2.5:cloud" || name == "qwen3.5:cloud"
|
||||
isRec := name == "gemma4" || name == "qwen3.5" || name == "minimax-m2.7:cloud" || name == "glm-5.1:cloud" || name == "kimi-k2.5:cloud" || name == "qwen3.5:cloud"
|
||||
if isRec && i > lastRecIdx {
|
||||
lastRecIdx = i
|
||||
}
|
||||
@@ -599,7 +634,7 @@ func TestBuildModelList_RecsAboveNonRecs(t *testing.T) {
|
||||
func TestBuildModelList_CheckedBeforeRecs(t *testing.T) {
|
||||
existing := []modelInfo{
|
||||
{Name: "llama3.2:latest", Remote: false},
|
||||
{Name: "glm-5:cloud", Remote: true},
|
||||
{Name: "glm-5.1:cloud", Remote: true},
|
||||
}
|
||||
|
||||
items, _, _, _ := buildModelList(existing, []string{"llama3.2"}, "")
|
||||
@@ -680,7 +715,7 @@ func TestLauncherClientFilterDisabledCloudModels_ChecksStatusOncePerInvocation(t
|
||||
apiClient: api.NewClient(u, srv.Client()),
|
||||
}
|
||||
|
||||
filtered := client.filterDisabledCloudModels(context.Background(), []string{"llama3.2", "glm-5:cloud", "qwen3.5:cloud"})
|
||||
filtered := client.filterDisabledCloudModels(context.Background(), []string{"llama3.2", "glm-5.1:cloud", "qwen3.5:cloud"})
|
||||
if diff := cmp.Diff([]string{"llama3.2"}, filtered); diff != "" {
|
||||
t.Fatalf("filtered models mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
@@ -689,6 +724,59 @@ func TestLauncherClientFilterDisabledCloudModels_ChecksStatusOncePerInvocation(t
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavedMatchesModels(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
saved *config.IntegrationConfig
|
||||
models []string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "nil saved",
|
||||
saved: nil,
|
||||
models: []string{"llama3.2"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "identical order",
|
||||
saved: &config.IntegrationConfig{Models: []string{"llama3.2", "qwen3:8b"}},
|
||||
models: []string{"llama3.2", "qwen3:8b"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "different order",
|
||||
saved: &config.IntegrationConfig{Models: []string{"llama3.2", "qwen3:8b"}},
|
||||
models: []string{"qwen3:8b", "llama3.2"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "subset",
|
||||
saved: &config.IntegrationConfig{Models: []string{"llama3.2", "qwen3:8b"}},
|
||||
models: []string{"llama3.2"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "nil models in saved with non-nil models",
|
||||
saved: &config.IntegrationConfig{Models: nil},
|
||||
models: []string{"llama3.2"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty both",
|
||||
saved: &config.IntegrationConfig{Models: nil},
|
||||
models: nil,
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := savedMatchesModels(tt.saved, tt.models); got != tt.want {
|
||||
t.Fatalf("savedMatchesModels = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareEditorIntegration_SavesOnlyAfterSuccessfulEdit(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
@@ -754,7 +842,7 @@ func TestShowOrPullWithPolicy_ModelExists(t *testing.T) {
|
||||
|
||||
func TestShowOrPullWithPolicy_ModelNotFound_FailDoesNotPromptOrPull(t *testing.T) {
|
||||
oldHook := DefaultConfirmPrompt
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
t.Fatal("confirm prompt should not be called with fail policy")
|
||||
return false, nil
|
||||
}
|
||||
@@ -793,7 +881,7 @@ func TestShowOrPullWithPolicy_ModelNotFound_FailDoesNotPromptOrPull(t *testing.T
|
||||
|
||||
func TestShowOrPullWithPolicy_ModelNotFound_PromptPolicyPulls(t *testing.T) {
|
||||
oldHook := DefaultConfirmPrompt
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
if !strings.Contains(prompt, "missing-model") {
|
||||
t.Fatalf("expected prompt to mention missing model, got %q", prompt)
|
||||
}
|
||||
@@ -831,7 +919,7 @@ func TestShowOrPullWithPolicy_ModelNotFound_PromptPolicyPulls(t *testing.T) {
|
||||
|
||||
func TestShowOrPullWithPolicy_ModelNotFound_AutoPullPolicyPullsWithoutPrompt(t *testing.T) {
|
||||
oldHook := DefaultConfirmPrompt
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
t.Fatalf("confirm prompt should not be called with auto-pull policy: %q", prompt)
|
||||
return false, nil
|
||||
}
|
||||
@@ -867,7 +955,7 @@ func TestShowOrPullWithPolicy_ModelNotFound_AutoPullPolicyPullsWithoutPrompt(t *
|
||||
|
||||
func TestShowOrPullWithPolicy_CloudModelNotFound_FailsEarlyForAllPolicies(t *testing.T) {
|
||||
oldHook := DefaultConfirmPrompt
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
t.Fatal("confirm prompt should not be called for explicit cloud models")
|
||||
return false, nil
|
||||
}
|
||||
@@ -897,11 +985,11 @@ func TestShowOrPullWithPolicy_CloudModelNotFound_FailsEarlyForAllPolicies(t *tes
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
err := showOrPullWithPolicy(context.Background(), client, "glm-5:cloud", policy, true)
|
||||
err := showOrPullWithPolicy(context.Background(), client, "glm-5.1:cloud", policy, true)
|
||||
if err == nil {
|
||||
t.Fatalf("expected cloud model not-found error for policy %d", policy)
|
||||
}
|
||||
if !strings.Contains(err.Error(), `model "glm-5:cloud" not found`) {
|
||||
if !strings.Contains(err.Error(), `model "glm-5.1:cloud" not found`) {
|
||||
t.Fatalf("expected not-found error for policy %d, got %v", policy, err)
|
||||
}
|
||||
if pullCalled {
|
||||
@@ -913,7 +1001,7 @@ func TestShowOrPullWithPolicy_CloudModelNotFound_FailsEarlyForAllPolicies(t *tes
|
||||
|
||||
func TestShowOrPullWithPolicy_CloudModelDisabled_FailsWithCloudDisabledError(t *testing.T) {
|
||||
oldHook := DefaultConfirmPrompt
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
t.Fatal("confirm prompt should not be called for explicit cloud models")
|
||||
return false, nil
|
||||
}
|
||||
@@ -943,7 +1031,7 @@ func TestShowOrPullWithPolicy_CloudModelDisabled_FailsWithCloudDisabledError(t *
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
err := showOrPullWithPolicy(context.Background(), client, "glm-5:cloud", policy, true)
|
||||
err := showOrPullWithPolicy(context.Background(), client, "glm-5.1:cloud", policy, true)
|
||||
if err == nil {
|
||||
t.Fatalf("expected cloud disabled error for policy %d", policy)
|
||||
}
|
||||
@@ -1002,7 +1090,7 @@ func TestShowOrPull_ShowCalledWithCorrectModel(t *testing.T) {
|
||||
func TestShowOrPull_ModelNotFound_ConfirmYes_Pulls(t *testing.T) {
|
||||
// Set up hook so confirmPrompt doesn't need a terminal
|
||||
oldHook := DefaultConfirmPrompt
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
if !strings.Contains(prompt, "missing-model") {
|
||||
t.Errorf("expected prompt to contain model name, got %q", prompt)
|
||||
}
|
||||
@@ -1040,7 +1128,7 @@ func TestShowOrPull_ModelNotFound_ConfirmYes_Pulls(t *testing.T) {
|
||||
|
||||
func TestShowOrPull_ModelNotFound_ConfirmNo_Cancelled(t *testing.T) {
|
||||
oldHook := DefaultConfirmPrompt
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
return false, ErrCancelled
|
||||
}
|
||||
defer func() { DefaultConfirmPrompt = oldHook }()
|
||||
@@ -1070,7 +1158,7 @@ func TestShowOrPull_ModelNotFound_ConfirmNo_Cancelled(t *testing.T) {
|
||||
func TestShowOrPull_CloudModel_NotFoundDoesNotPull(t *testing.T) {
|
||||
// Confirm prompt should NOT be called for explicit cloud models
|
||||
oldHook := DefaultConfirmPrompt
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
t.Error("confirm prompt should not be called for cloud models")
|
||||
return false, nil
|
||||
}
|
||||
@@ -1095,11 +1183,11 @@ func TestShowOrPull_CloudModel_NotFoundDoesNotPull(t *testing.T) {
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
err := showOrPullWithPolicy(context.Background(), client, "glm-5:cloud", missingModelPromptPull, true)
|
||||
err := showOrPullWithPolicy(context.Background(), client, "glm-5.1:cloud", missingModelPromptPull, true)
|
||||
if err == nil {
|
||||
t.Error("ShowOrPull should return not-found error for cloud model")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `model "glm-5:cloud" not found`) {
|
||||
if !strings.Contains(err.Error(), `model "glm-5.1:cloud" not found`) {
|
||||
t.Errorf("expected cloud model not-found error, got: %v", err)
|
||||
}
|
||||
if pullCalled {
|
||||
@@ -1110,7 +1198,7 @@ func TestShowOrPull_CloudModel_NotFoundDoesNotPull(t *testing.T) {
|
||||
func TestShowOrPull_CloudLegacySuffix_NotFoundDoesNotPull(t *testing.T) {
|
||||
// Confirm prompt should NOT be called for explicit cloud models
|
||||
oldHook := DefaultConfirmPrompt
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
t.Error("confirm prompt should not be called for cloud models")
|
||||
return false, nil
|
||||
}
|
||||
@@ -1150,7 +1238,7 @@ func TestShowOrPull_CloudLegacySuffix_NotFoundDoesNotPull(t *testing.T) {
|
||||
func TestConfirmPrompt_DelegatesToHook(t *testing.T) {
|
||||
oldHook := DefaultConfirmPrompt
|
||||
var hookCalled bool
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
hookCalled = true
|
||||
if prompt != "test prompt?" {
|
||||
t.Errorf("expected prompt %q, got %q", "test prompt?", prompt)
|
||||
@@ -1274,7 +1362,7 @@ func TestEnsureAuth_PreservesCancelledSignInHook(t *testing.T) {
|
||||
|
||||
func TestEnsureAuth_DeclinedFallbackReturnsCancelled(t *testing.T) {
|
||||
oldConfirm := DefaultConfirmPrompt
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
defer func() { DefaultConfirmPrompt = oldConfirm }()
|
||||
@@ -1422,27 +1510,13 @@ func TestListIntegrationInfos(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sorted with custom order at end", func(t *testing.T) {
|
||||
// integrationOrder entries (cline, opencode) should appear last, in that order.
|
||||
// All other entries should be sorted alphabetically before them.
|
||||
orderRank := make(map[string]int)
|
||||
for i, name := range integrationOrder {
|
||||
orderRank[name] = i + 1
|
||||
t.Run("follows launcher order", func(t *testing.T) {
|
||||
got := make([]string, 0, len(infos))
|
||||
for _, info := range infos {
|
||||
got = append(got, info.Name)
|
||||
}
|
||||
for i := 1; i < len(infos); i++ {
|
||||
aRank, bRank := orderRank[infos[i-1].Name], orderRank[infos[i].Name]
|
||||
switch {
|
||||
case aRank == 0 && bRank == 0:
|
||||
if infos[i-1].Name >= infos[i].Name {
|
||||
t.Errorf("non-ordered items not sorted: %q >= %q", infos[i-1].Name, infos[i].Name)
|
||||
}
|
||||
case aRank > 0 && bRank == 0:
|
||||
t.Errorf("ordered item %q should come after non-ordered %q", infos[i-1].Name, infos[i].Name)
|
||||
case aRank > 0 && bRank > 0:
|
||||
if aRank >= bRank {
|
||||
t.Errorf("ordered items wrong: %q (rank %d) before %q (rank %d)", infos[i-1].Name, aRank, infos[i].Name, bRank)
|
||||
}
|
||||
}
|
||||
if diff := compareStrings(got, integrationOrder); diff != "" {
|
||||
t.Fatalf("launcher integration order mismatch: %s", diff)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1470,6 +1544,28 @@ func TestListIntegrationInfos(t *testing.T) {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("includes hermes", func(t *testing.T) {
|
||||
for _, info := range infos {
|
||||
if info.Name == "hermes" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("expected hermes to be included in ListIntegrationInfos")
|
||||
})
|
||||
|
||||
t.Run("hermes still resolves explicitly", func(t *testing.T) {
|
||||
name, runner, err := LookupIntegration("hermes")
|
||||
if err != nil {
|
||||
t.Fatalf("expected explicit hermes integration lookup to work, got %v", err)
|
||||
}
|
||||
if name != "hermes" {
|
||||
t.Fatalf("expected canonical name hermes, got %q", name)
|
||||
}
|
||||
if runner.String() == "" {
|
||||
t.Fatal("expected hermes integration runner to be present")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildModelList_Descriptions(t *testing.T) {
|
||||
@@ -1558,6 +1654,7 @@ func TestIntegration_AutoInstallable(t *testing.T) {
|
||||
}{
|
||||
{"openclaw", true},
|
||||
{"pi", true},
|
||||
{"hermes", true},
|
||||
{"claude", false},
|
||||
{"codex", false},
|
||||
{"opencode", false},
|
||||
|
||||
+211
-55
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
@@ -140,6 +141,36 @@ type Editor interface {
|
||||
Models() []string
|
||||
}
|
||||
|
||||
// ManagedSingleModel is the narrow launch-owned config path for integrations
|
||||
// like Hermes that have one primary model selected by launcher, need launcher
|
||||
// to persist minimal config, and still keep their own model discovery and
|
||||
// onboarding UX. This stays separate from Runner-only integrations and the
|
||||
// multi-model Editor flow so Hermes-specific behavior stays scoped to one path.
|
||||
type ManagedSingleModel interface {
|
||||
Paths() []string
|
||||
Configure(model string) error
|
||||
CurrentModel() string
|
||||
Onboard() error
|
||||
}
|
||||
|
||||
// ManagedRuntimeRefresher lets managed integrations refresh any long-lived
|
||||
// background runtime after launch rewrites their config.
|
||||
type ManagedRuntimeRefresher interface {
|
||||
RefreshRuntimeAfterConfigure() error
|
||||
}
|
||||
|
||||
// ManagedOnboardingValidator lets managed integrations re-check saved
|
||||
// onboarding state when launcher needs a stronger live readiness signal.
|
||||
type ManagedOnboardingValidator interface {
|
||||
OnboardingComplete() bool
|
||||
}
|
||||
|
||||
// ManagedInteractiveOnboarding lets a managed integration declare whether its
|
||||
// onboarding step really requires an interactive terminal. Hermes does not.
|
||||
type ManagedInteractiveOnboarding interface {
|
||||
RequiresInteractiveOnboarding() bool
|
||||
}
|
||||
|
||||
type modelInfo struct {
|
||||
Name string
|
||||
Remote bool
|
||||
@@ -175,7 +206,9 @@ Supported integrations:
|
||||
claude Claude Code
|
||||
cline Cline
|
||||
codex Codex
|
||||
copilot Copilot CLI (aliases: copilot-cli)
|
||||
droid Droid
|
||||
hermes Hermes Agent
|
||||
opencode OpenCode
|
||||
openclaw OpenClaw (aliases: clawdbot, moltbot)
|
||||
pi Pi
|
||||
@@ -185,6 +218,7 @@ Examples:
|
||||
ollama launch
|
||||
ollama launch claude
|
||||
ollama launch claude --model <model>
|
||||
ollama launch hermes
|
||||
ollama launch droid --config (does not auto-launch)
|
||||
ollama launch codex -- -p myprofile (pass extra args to integration)
|
||||
ollama launch codex -- --sandbox workspace-write`,
|
||||
@@ -307,36 +341,54 @@ func LaunchIntegration(ctx context.Context, req IntegrationLaunchRequest) error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
policy := launchIntegrationPolicy(req)
|
||||
if policy.Confirm == LaunchConfirmAutoApprove && !isInteractiveSession() && req.ModelOverride == "" {
|
||||
return fmt.Errorf("headless --yes launch for %s requires --model <model>", name)
|
||||
}
|
||||
|
||||
launchClient, saved, err := prepareIntegrationLaunch(name, policy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if managed, ok := runner.(ManagedSingleModel); ok {
|
||||
if err := EnsureIntegrationInstalled(name, runner); err != nil {
|
||||
return err
|
||||
}
|
||||
return launchClient.launchManagedSingleIntegration(ctx, name, runner, managed, saved, req)
|
||||
}
|
||||
|
||||
if !req.ConfigureOnly {
|
||||
if err := EnsureIntegrationInstalled(name, runner); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var policy LaunchPolicy
|
||||
// TUI does not set a policy, whereas ollama launch <app> does as it can have flags which change the behavior
|
||||
if req.Policy == nil {
|
||||
policy = defaultLaunchPolicy(isInteractiveSession(), false)
|
||||
} else {
|
||||
policy = *req.Policy
|
||||
}
|
||||
|
||||
launchClient, err := newLauncherClient(policy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
saved, _ := loadStoredIntegrationConfig(name)
|
||||
// In headless --yes mode we cannot prompt, so require an explicit --model.
|
||||
if policy.Confirm == LaunchConfirmAutoApprove && !isInteractiveSession() && req.ModelOverride == "" {
|
||||
return fmt.Errorf("headless --yes launch for %s requires --model <model>", name)
|
||||
}
|
||||
|
||||
if editor, ok := runner.(Editor); ok {
|
||||
return launchClient.launchEditorIntegration(ctx, name, runner, editor, saved, req)
|
||||
}
|
||||
return launchClient.launchSingleIntegration(ctx, name, runner, saved, req)
|
||||
}
|
||||
|
||||
func launchIntegrationPolicy(req IntegrationLaunchRequest) LaunchPolicy {
|
||||
// TUI does not set a policy, whereas ollama launch <app> does as it can
|
||||
// have flags which change the behavior.
|
||||
if req.Policy != nil {
|
||||
return *req.Policy
|
||||
}
|
||||
return defaultLaunchPolicy(isInteractiveSession(), false)
|
||||
}
|
||||
|
||||
func prepareIntegrationLaunch(name string, policy LaunchPolicy) (*launcherClient, *config.IntegrationConfig, error) {
|
||||
launchClient, err := newLauncherClient(policy)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
saved, _ := loadStoredIntegrationConfig(name)
|
||||
return launchClient, saved, nil
|
||||
}
|
||||
|
||||
func (c *launcherClient) buildLauncherState(ctx context.Context) (*LauncherState, error) {
|
||||
_ = c.loadModelInventoryOnce(ctx)
|
||||
|
||||
@@ -367,9 +419,18 @@ func (c *launcherClient) buildLauncherIntegrationState(ctx context.Context, info
|
||||
if err != nil {
|
||||
return LauncherIntegrationState{}, err
|
||||
}
|
||||
currentModel, usable, err := c.launcherModelState(ctx, info.Name, integration.editor)
|
||||
if err != nil {
|
||||
return LauncherIntegrationState{}, err
|
||||
var currentModel string
|
||||
var usable bool
|
||||
if managed, ok := integration.spec.Runner.(ManagedSingleModel); ok {
|
||||
currentModel, usable, err = c.launcherManagedModelState(ctx, info.Name, managed)
|
||||
if err != nil {
|
||||
return LauncherIntegrationState{}, err
|
||||
}
|
||||
} else {
|
||||
currentModel, usable, err = c.launcherModelState(ctx, info.Name, integration.editor)
|
||||
if err != nil {
|
||||
return LauncherIntegrationState{}, err
|
||||
}
|
||||
}
|
||||
|
||||
return LauncherIntegrationState{
|
||||
@@ -407,6 +468,28 @@ func (c *launcherClient) launcherModelState(ctx context.Context, name string, is
|
||||
return model, usableErr == nil && usable, nil
|
||||
}
|
||||
|
||||
func (c *launcherClient) launcherManagedModelState(ctx context.Context, name string, managed ManagedSingleModel) (string, bool, error) {
|
||||
current := managed.CurrentModel()
|
||||
if current == "" {
|
||||
cfg, loadErr := loadStoredIntegrationConfig(name)
|
||||
if loadErr == nil {
|
||||
current = primaryModelFromConfig(cfg)
|
||||
}
|
||||
if current != "" {
|
||||
return current, false, nil
|
||||
}
|
||||
}
|
||||
if current == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
usable, err := c.savedModelUsable(ctx, current)
|
||||
if err != nil {
|
||||
return current, false, err
|
||||
}
|
||||
return current, usable, nil
|
||||
}
|
||||
|
||||
func (c *launcherClient) resolveRunModel(ctx context.Context, req RunModelRequest) (string, error) {
|
||||
current := config.LastModel()
|
||||
if !req.ForcePicker && current != "" && c.policy.Confirm == LaunchConfirmAutoApprove && !isInteractiveSession() {
|
||||
@@ -443,35 +526,15 @@ func (c *launcherClient) resolveRunModel(ctx context.Context, req RunModelReques
|
||||
}
|
||||
|
||||
func (c *launcherClient) launchSingleIntegration(ctx context.Context, name string, runner Runner, saved *config.IntegrationConfig, req IntegrationLaunchRequest) error {
|
||||
current := primaryModelFromConfig(saved)
|
||||
target := req.ModelOverride
|
||||
needsConfigure := req.ForceConfigure
|
||||
|
||||
if target == "" {
|
||||
target = current
|
||||
usable, err := c.savedModelUsable(ctx, target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !usable {
|
||||
needsConfigure = true
|
||||
}
|
||||
}
|
||||
|
||||
if needsConfigure {
|
||||
selected, err := c.selectSingleModelWithSelector(ctx, fmt.Sprintf("Select model for %s:", runner), target, DefaultSingleSelector)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target = selected
|
||||
} else if err := c.ensureModelsReady(ctx, []string{target}); err != nil {
|
||||
target, _, err := c.resolveSingleIntegrationTarget(ctx, runner, primaryModelFromConfig(saved), req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if target == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
current := primaryModelFromConfig(saved)
|
||||
if target != current {
|
||||
if err := config.SaveIntegration(name, []string{target}); err != nil {
|
||||
return fmt.Errorf("failed to save: %w", err)
|
||||
@@ -500,7 +563,7 @@ func (c *launcherClient) launchEditorIntegration(ctx context.Context, name strin
|
||||
return nil
|
||||
}
|
||||
|
||||
if needsConfigure || req.ModelOverride != "" {
|
||||
if (needsConfigure || req.ModelOverride != "") && !savedMatchesModels(saved, models) {
|
||||
if err := prepareEditorIntegration(name, runner, editor, models); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -509,6 +572,102 @@ func (c *launcherClient) launchEditorIntegration(ctx context.Context, name strin
|
||||
return launchAfterConfiguration(name, runner, models[0], req)
|
||||
}
|
||||
|
||||
func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, name string, runner Runner, managed ManagedSingleModel, saved *config.IntegrationConfig, req IntegrationLaunchRequest) error {
|
||||
current := managed.CurrentModel()
|
||||
selectionCurrent := current
|
||||
if selectionCurrent == "" {
|
||||
selectionCurrent = primaryModelFromConfig(saved)
|
||||
}
|
||||
|
||||
target, needsConfigure, err := c.resolveSingleIntegrationTarget(ctx, runner, selectionCurrent, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if target == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if current == "" || needsConfigure || req.ModelOverride != "" || target != current {
|
||||
if err := prepareManagedSingleIntegration(name, runner, managed, target); err != nil {
|
||||
return err
|
||||
}
|
||||
if refresher, ok := managed.(ManagedRuntimeRefresher); ok {
|
||||
if err := refresher.RefreshRuntimeAfterConfigure(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !managedIntegrationOnboarded(saved, managed) {
|
||||
if !isInteractiveSession() && managedRequiresInteractiveOnboarding(managed) {
|
||||
return fmt.Errorf("%s still needs interactive gateway setup; run 'ollama launch %s' in a terminal to finish onboarding", runner, name)
|
||||
}
|
||||
if err := managed.Onboard(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if req.ConfigureOnly {
|
||||
return nil
|
||||
}
|
||||
|
||||
return runIntegration(runner, target, req.ExtraArgs)
|
||||
}
|
||||
|
||||
func (c *launcherClient) resolveSingleIntegrationTarget(ctx context.Context, runner Runner, current string, req IntegrationLaunchRequest) (string, bool, error) {
|
||||
target := req.ModelOverride
|
||||
needsConfigure := req.ForceConfigure
|
||||
|
||||
if target == "" {
|
||||
target = current
|
||||
usable, err := c.savedModelUsable(ctx, target)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if !usable {
|
||||
needsConfigure = true
|
||||
}
|
||||
}
|
||||
|
||||
if needsConfigure {
|
||||
selected, err := c.selectSingleModelWithSelector(ctx, fmt.Sprintf("Select model for %s:", runner), target, DefaultSingleSelector)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
target = selected
|
||||
} else if err := c.ensureModelsReady(ctx, []string{target}); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
return target, needsConfigure, nil
|
||||
}
|
||||
|
||||
func savedIntegrationOnboarded(saved *config.IntegrationConfig) bool {
|
||||
return saved != nil && saved.Onboarded
|
||||
}
|
||||
|
||||
func managedIntegrationOnboarded(saved *config.IntegrationConfig, managed ManagedSingleModel) bool {
|
||||
if !savedIntegrationOnboarded(saved) {
|
||||
return false
|
||||
}
|
||||
validator, ok := managed.(ManagedOnboardingValidator)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
return validator.OnboardingComplete()
|
||||
}
|
||||
|
||||
// Most managed integrations treat onboarding as an interactive terminal step.
|
||||
// Hermes opts out because its launch-owned onboarding is just bookkeeping, so
|
||||
// headless launches should not be blocked once config is already prepared.
|
||||
func managedRequiresInteractiveOnboarding(managed ManagedSingleModel) bool {
|
||||
onboarding, ok := managed.(ManagedInteractiveOnboarding)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
return onboarding.RequiresInteractiveOnboarding()
|
||||
}
|
||||
|
||||
func (c *launcherClient) selectSingleModelWithSelector(ctx context.Context, title, current string, selector SingleSelector) (string, error) {
|
||||
if selector == nil {
|
||||
return "", fmt.Errorf("no selector configured")
|
||||
@@ -540,15 +699,6 @@ func (c *launcherClient) selectMultiModelsForIntegration(ctx context.Context, ru
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(preChecked) > 0 {
|
||||
// Keep list order stable in multi-select even when there are existing checks.
|
||||
// checked/default state still comes from orderedChecked.
|
||||
stableItems, _, stableErr := c.loadSelectableModels(ctx, nil, current, "no models available")
|
||||
if stableErr != nil {
|
||||
return nil, stableErr
|
||||
}
|
||||
items = stableItems
|
||||
}
|
||||
|
||||
selected, err := DefaultMultiSelector(fmt.Sprintf("Select models for %s:", runner), items, orderedChecked)
|
||||
if err != nil {
|
||||
@@ -765,7 +915,6 @@ func (c *launcherClient) loadModelInventoryOnce(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func runIntegration(runner Runner, modelName string, args []string) error {
|
||||
fmt.Fprintf(os.Stderr, "\nLaunching %s with %s...\n", runner, modelName)
|
||||
return runner.Run(modelName, args)
|
||||
}
|
||||
|
||||
@@ -856,6 +1005,13 @@ func firstModel(models []string) string {
|
||||
return models[0]
|
||||
}
|
||||
|
||||
func savedMatchesModels(saved *config.IntegrationConfig, models []string) bool {
|
||||
if saved == nil {
|
||||
return false
|
||||
}
|
||||
return slices.Equal(saved.Models, models)
|
||||
}
|
||||
|
||||
func editorPreCheckedModels(saved *config.IntegrationConfig, override string) []string {
|
||||
if override == "" {
|
||||
if saved == nil {
|
||||
|
||||
+511
-20
@@ -49,6 +49,55 @@ func (r *launcherSingleRunner) Run(model string, args []string) error {
|
||||
|
||||
func (r *launcherSingleRunner) String() string { return "StubSingle" }
|
||||
|
||||
type launcherManagedRunner struct {
|
||||
paths []string
|
||||
currentModel string
|
||||
configured []string
|
||||
ranModel string
|
||||
onboarded bool
|
||||
onboardCalls int
|
||||
onboardingComplete bool
|
||||
refreshCalls int
|
||||
refreshErr error
|
||||
}
|
||||
|
||||
func (r *launcherManagedRunner) Run(model string, args []string) error {
|
||||
r.ranModel = model
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *launcherManagedRunner) String() string { return "StubManaged" }
|
||||
|
||||
func (r *launcherManagedRunner) Paths() []string { return r.paths }
|
||||
|
||||
func (r *launcherManagedRunner) Configure(model string) error {
|
||||
r.configured = append(r.configured, model)
|
||||
r.currentModel = model
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *launcherManagedRunner) CurrentModel() string { return r.currentModel }
|
||||
|
||||
func (r *launcherManagedRunner) Onboard() error {
|
||||
r.onboardCalls++
|
||||
r.onboarded = true
|
||||
r.onboardingComplete = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *launcherManagedRunner) OnboardingComplete() bool { return r.onboardingComplete }
|
||||
|
||||
func (r *launcherManagedRunner) RefreshRuntimeAfterConfigure() error {
|
||||
r.refreshCalls++
|
||||
return r.refreshErr
|
||||
}
|
||||
|
||||
type launcherHeadlessManagedRunner struct {
|
||||
launcherManagedRunner
|
||||
}
|
||||
|
||||
func (r *launcherHeadlessManagedRunner) RequiresInteractiveOnboarding() bool { return false }
|
||||
|
||||
func setLaunchTestHome(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
t.Setenv("HOME", dir)
|
||||
@@ -141,6 +190,448 @@ func TestDefaultLaunchPolicy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLauncherState_ManagedSingleIntegrationUsesCurrentModel(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setLaunchTestHome(t, tmpDir)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/tags":
|
||||
fmt.Fprint(w, `{"models":[{"name":"gemma4"}]}`)
|
||||
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 := &launcherManagedRunner{currentModel: "gemma4"}
|
||||
withIntegrationOverride(t, "pi", runner)
|
||||
|
||||
state, err := BuildLauncherState(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("BuildLauncherState returned error: %v", err)
|
||||
}
|
||||
|
||||
if state.Integrations["pi"].CurrentModel != "gemma4" {
|
||||
t.Fatalf("expected managed current model from integration config, got %q", state.Integrations["pi"].CurrentModel)
|
||||
}
|
||||
if !state.Integrations["pi"].ModelUsable {
|
||||
t.Fatal("expected managed current model to be usable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLauncherState_ManagedSingleIntegrationShowsSavedModelWhenLiveConfigMissing(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setLaunchTestHome(t, tmpDir)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/tags":
|
||||
fmt.Fprint(w, `{"models":[{"name":"gemma4"}]}`)
|
||||
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)
|
||||
|
||||
if err := config.SaveIntegration("pi", []string{"gemma4"}); err != nil {
|
||||
t.Fatalf("failed to save managed integration config: %v", err)
|
||||
}
|
||||
|
||||
runner := &launcherManagedRunner{}
|
||||
withIntegrationOverride(t, "pi", runner)
|
||||
|
||||
state, err := BuildLauncherState(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("BuildLauncherState returned error: %v", err)
|
||||
}
|
||||
|
||||
if state.Integrations["pi"].CurrentModel != "gemma4" {
|
||||
t.Fatalf("expected saved model to remain visible, got %q", state.Integrations["pi"].CurrentModel)
|
||||
}
|
||||
if state.Integrations["pi"].ModelUsable {
|
||||
t.Fatal("expected missing live config to mark managed model unusable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchIntegration_ManagedSingleIntegrationConfiguresOnboardsAndRuns(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/tags":
|
||||
fmt.Fprint(w, `{"models":[{"name":"gemma4"}]}`)
|
||||
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 := &launcherManagedRunner{
|
||||
paths: nil,
|
||||
}
|
||||
withIntegrationOverride(t, "stubmanaged", runner)
|
||||
|
||||
DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) {
|
||||
return "gemma4", nil
|
||||
}
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "stubmanaged"}); err != nil {
|
||||
t.Fatalf("LaunchIntegration returned error: %v", err)
|
||||
}
|
||||
|
||||
if diff := compareStrings(runner.configured, []string{"gemma4"}); diff != "" {
|
||||
t.Fatalf("configured models mismatch: %s", diff)
|
||||
}
|
||||
if runner.refreshCalls != 1 {
|
||||
t.Fatalf("expected runtime refresh once after configure, got %d", runner.refreshCalls)
|
||||
}
|
||||
if runner.onboardCalls != 1 {
|
||||
t.Fatalf("expected onboarding to run once, got %d", runner.onboardCalls)
|
||||
}
|
||||
if runner.ranModel != "gemma4" {
|
||||
t.Fatalf("expected launch to run configured model, got %q", runner.ranModel)
|
||||
}
|
||||
|
||||
saved, err := config.LoadIntegration("stubmanaged")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to reload managed integration config: %v", err)
|
||||
}
|
||||
if diff := compareStrings(saved.Models, []string{"gemma4"}); diff != "" {
|
||||
t.Fatalf("saved models mismatch: %s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchIntegration_ManagedSingleIntegrationReOnboardsWhenSavedFlagIsStale(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/tags":
|
||||
fmt.Fprint(w, `{"models":[{"name":"gemma4"}]}`)
|
||||
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 := &launcherManagedRunner{
|
||||
currentModel: "gemma4",
|
||||
onboardingComplete: false,
|
||||
}
|
||||
withIntegrationOverride(t, "stubmanaged", runner)
|
||||
|
||||
if err := config.SaveIntegration("stubmanaged", []string{"gemma4"}); err != nil {
|
||||
t.Fatalf("failed to save managed integration config: %v", err)
|
||||
}
|
||||
if err := config.MarkIntegrationOnboarded("stubmanaged"); err != nil {
|
||||
t.Fatalf("failed to mark managed integration onboarded: %v", err)
|
||||
}
|
||||
|
||||
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "stubmanaged"}); err != nil {
|
||||
t.Fatalf("LaunchIntegration returned error: %v", err)
|
||||
}
|
||||
|
||||
if runner.onboardCalls != 1 {
|
||||
t.Fatalf("expected stale onboarded flag to trigger onboarding, got %d calls", runner.onboardCalls)
|
||||
}
|
||||
if runner.refreshCalls != 0 {
|
||||
t.Fatalf("expected no runtime refresh when config is unchanged, got %d", runner.refreshCalls)
|
||||
}
|
||||
if runner.ranModel != "gemma4" {
|
||||
t.Fatalf("expected launch to run saved model after onboarding repair, got %q", runner.ranModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchIntegration_ManagedSingleIntegrationConfigOnlySkipsFinalRun(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/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 := &launcherManagedRunner{
|
||||
paths: nil,
|
||||
}
|
||||
withIntegrationOverride(t, "stubmanaged", runner)
|
||||
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
|
||||
Name: "stubmanaged",
|
||||
ModelOverride: "gemma4",
|
||||
ConfigureOnly: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("LaunchIntegration returned error: %v", err)
|
||||
}
|
||||
|
||||
if runner.ranModel != "" {
|
||||
t.Fatalf("expected configure-only flow to skip final launch, got %q", runner.ranModel)
|
||||
}
|
||||
if runner.refreshCalls != 1 {
|
||||
t.Fatalf("expected configure-only flow to refresh runtime once, got %d", runner.refreshCalls)
|
||||
}
|
||||
if runner.onboardCalls != 1 {
|
||||
t.Fatalf("expected configure-only flow to onboard once, got %d", runner.onboardCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchIntegration_ManagedSingleIntegrationRepairsMissingLiveConfigUsingSavedModel(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/tags":
|
||||
fmt.Fprint(w, `{"models":[{"name":"gemma4"}]}`)
|
||||
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)
|
||||
|
||||
if err := config.SaveIntegration("stubmanaged", []string{"gemma4"}); err != nil {
|
||||
t.Fatalf("failed to save managed integration config: %v", err)
|
||||
}
|
||||
|
||||
runner := &launcherManagedRunner{}
|
||||
withIntegrationOverride(t, "stubmanaged", runner)
|
||||
|
||||
DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) {
|
||||
t.Fatal("selector should not be called when saved model is reused for repair")
|
||||
return "", nil
|
||||
}
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "stubmanaged"}); err != nil {
|
||||
t.Fatalf("LaunchIntegration returned error: %v", err)
|
||||
}
|
||||
|
||||
if diff := compareStrings(runner.configured, []string{"gemma4"}); diff != "" {
|
||||
t.Fatalf("expected missing live config to be rewritten from saved model: %s", diff)
|
||||
}
|
||||
if runner.refreshCalls != 1 {
|
||||
t.Fatalf("expected repaired config to refresh runtime once, got %d", runner.refreshCalls)
|
||||
}
|
||||
if runner.ranModel != "gemma4" {
|
||||
t.Fatalf("expected launch to use repaired saved model, got %q", runner.ranModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchIntegration_ManagedSingleIntegrationConfigureOnlyRepairsMissingLiveConfig(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/show":
|
||||
fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||
|
||||
if err := config.SaveIntegration("stubmanaged", []string{"gemma4"}); err != nil {
|
||||
t.Fatalf("failed to save managed integration config: %v", err)
|
||||
}
|
||||
|
||||
runner := &launcherManagedRunner{}
|
||||
withIntegrationOverride(t, "stubmanaged", runner)
|
||||
|
||||
DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) {
|
||||
t.Fatal("selector should not be called when saved model is reused for repair")
|
||||
return "", nil
|
||||
}
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
|
||||
Name: "stubmanaged",
|
||||
ConfigureOnly: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("LaunchIntegration returned error: %v", err)
|
||||
}
|
||||
|
||||
if diff := compareStrings(runner.configured, []string{"gemma4"}); diff != "" {
|
||||
t.Fatalf("expected configure-only flow to rewrite missing live config: %s", diff)
|
||||
}
|
||||
if runner.refreshCalls != 1 {
|
||||
t.Fatalf("expected configure-only repair to refresh runtime once, got %d", runner.refreshCalls)
|
||||
}
|
||||
if runner.ranModel != "" {
|
||||
t.Fatalf("expected configure-only flow to skip final launch, got %q", runner.ranModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchIntegration_ManagedSingleIntegrationStopsWhenRuntimeRefreshFails(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/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 := &launcherManagedRunner{
|
||||
refreshErr: fmt.Errorf("boom"),
|
||||
}
|
||||
withIntegrationOverride(t, "stubmanaged", runner)
|
||||
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
|
||||
Name: "stubmanaged",
|
||||
ModelOverride: "gemma4",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "boom") {
|
||||
t.Fatalf("expected runtime refresh error, got %v", err)
|
||||
}
|
||||
if runner.ranModel != "" {
|
||||
t.Fatalf("expected final launch to stop on runtime refresh failure, got %q", runner.ranModel)
|
||||
}
|
||||
if runner.refreshCalls != 1 {
|
||||
t.Fatalf("expected one runtime refresh attempt, got %d", runner.refreshCalls)
|
||||
}
|
||||
if runner.onboardCalls != 0 {
|
||||
t.Fatalf("expected onboarding to stop after refresh failure, got %d", runner.onboardCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchIntegration_ManagedSingleIntegrationHeadlessNeedsInteractiveOnboarding(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setLaunchTestHome(t, tmpDir)
|
||||
withInteractiveSession(t, false)
|
||||
withLauncherHooks(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
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 := &launcherManagedRunner{
|
||||
paths: nil,
|
||||
}
|
||||
withIntegrationOverride(t, "stubmanaged", runner)
|
||||
|
||||
err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
|
||||
Name: "stubmanaged",
|
||||
ModelOverride: "gemma4",
|
||||
Policy: &LaunchPolicy{Confirm: LaunchConfirmAutoApprove, MissingModel: LaunchMissingModelAutoPull},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected headless onboarding requirement to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "interactive gateway setup") {
|
||||
t.Fatalf("expected interactive onboarding guidance, got %v", err)
|
||||
}
|
||||
if runner.ranModel != "" {
|
||||
t.Fatalf("expected no final launch when onboarding is still required, got %q", runner.ranModel)
|
||||
}
|
||||
if runner.onboardCalls != 0 {
|
||||
t.Fatalf("expected no onboarding attempts in headless mode, got %d", runner.onboardCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchIntegration_ManagedSingleIntegrationHeadlessAllowsNonInteractiveOnboarding(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setLaunchTestHome(t, tmpDir)
|
||||
withInteractiveSession(t, false)
|
||||
withLauncherHooks(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
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 := &launcherHeadlessManagedRunner{}
|
||||
withIntegrationOverride(t, "stubmanaged", runner)
|
||||
|
||||
err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
|
||||
Name: "stubmanaged",
|
||||
ModelOverride: "gemma4",
|
||||
Policy: &LaunchPolicy{Confirm: LaunchConfirmAutoApprove, MissingModel: LaunchMissingModelAutoPull},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected non-interactive onboarding to succeed headlessly, got %v", err)
|
||||
}
|
||||
if diff := compareStrings(runner.configured, []string{"gemma4"}); diff != "" {
|
||||
t.Fatalf("configured models mismatch: %s", diff)
|
||||
}
|
||||
if runner.onboardCalls != 1 {
|
||||
t.Fatalf("expected onboarding to run once, got %d", runner.onboardCalls)
|
||||
}
|
||||
if runner.ranModel != "gemma4" {
|
||||
t.Fatalf("expected launch to run configured model, got %q", runner.ranModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLauncherState_InstalledAndCloudDisabled(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setLaunchTestHome(t, tmpDir)
|
||||
@@ -521,7 +1012,7 @@ func TestResolveRunModel_ForcePicker_DoesNotReorderByLastModel(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/tags":
|
||||
fmt.Fprint(w, `{"models":[{"name":"qwen3.5"},{"name":"glm-4.7-flash"}]}`)
|
||||
fmt.Fprint(w, `{"models":[{"name":"qwen3.5"},{"name":"gemma4"}]}`)
|
||||
case "/api/show":
|
||||
fmt.Fprint(w, `{"model":"qwen3.5"}`)
|
||||
default:
|
||||
@@ -540,7 +1031,7 @@ func TestResolveRunModel_ForcePicker_DoesNotReorderByLastModel(t *testing.T) {
|
||||
t.Fatal("expected selector to receive model items")
|
||||
}
|
||||
|
||||
glmIdx := slices.Index(gotNames, "glm-4.7-flash")
|
||||
glmIdx := slices.Index(gotNames, "gemma4")
|
||||
qwenIdx := slices.Index(gotNames, "qwen3.5")
|
||||
if glmIdx == -1 || qwenIdx == -1 {
|
||||
t.Fatalf("expected recommended local models in selector items, got %v", gotNames)
|
||||
@@ -618,7 +1109,7 @@ func TestLaunchIntegration_EditorForceConfigure(t *testing.T) {
|
||||
}
|
||||
|
||||
var proceedPrompt bool
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
if prompt == "Proceed?" {
|
||||
proceedPrompt = true
|
||||
}
|
||||
@@ -668,7 +1159,7 @@ func TestLaunchIntegration_EditorForceConfigure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchIntegration_EditorForceConfigure_DoesNotFloatCheckedModelsInPicker(t *testing.T) {
|
||||
func TestLaunchIntegration_EditorForceConfigure_FloatsCheckedModelsInPicker(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setLaunchTestHome(t, tmpDir)
|
||||
withLauncherHooks(t)
|
||||
@@ -725,8 +1216,8 @@ func TestLaunchIntegration_EditorForceConfigure_DoesNotFloatCheckedModelsInPicke
|
||||
if len(gotItems) == 0 {
|
||||
t.Fatal("expected multi selector to receive items")
|
||||
}
|
||||
if gotItems[0] != "kimi-k2.5:cloud" {
|
||||
t.Fatalf("expected stable recommendation order with kimi-k2.5:cloud first, got %v", gotItems)
|
||||
if gotItems[0] != "qwen3.5:cloud" {
|
||||
t.Fatalf("expected checked models floated to top with qwen3.5:cloud first, got %v", gotItems)
|
||||
}
|
||||
if len(gotPreChecked) < 2 {
|
||||
t.Fatalf("expected prechecked models to be preserved, got %v", gotPreChecked)
|
||||
@@ -847,7 +1338,7 @@ func TestLaunchIntegration_EditorConfigureMultiSkipsMissingLocalAndPersistsAccep
|
||||
DefaultMultiSelector = func(title string, items []ModelItem, preChecked []string) ([]string, error) {
|
||||
return []string{"glm-5:cloud", "missing-local"}, nil
|
||||
}
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
if prompt == "Proceed?" {
|
||||
return true, nil
|
||||
}
|
||||
@@ -929,7 +1420,7 @@ func TestLaunchIntegration_EditorConfigureMultiSkipsUnauthedCloudAndPersistsAcce
|
||||
DefaultMultiSelector = func(title string, items []ModelItem, preChecked []string) ([]string, error) {
|
||||
return []string{"llama3.2", "glm-5:cloud"}, nil
|
||||
}
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
if prompt == "Proceed?" {
|
||||
return true, nil
|
||||
}
|
||||
@@ -1014,7 +1505,7 @@ func TestLaunchIntegration_EditorConfigureMultiRemovesReselectedFailingModel(t *
|
||||
DefaultMultiSelector = func(title string, items []ModelItem, preChecked []string) ([]string, error) {
|
||||
return append([]string(nil), preChecked...), nil
|
||||
}
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
if prompt == "Proceed?" {
|
||||
return true, nil
|
||||
}
|
||||
@@ -1101,7 +1592,7 @@ func TestLaunchIntegration_EditorConfigureMultiAllFailuresKeepsExistingAndSkipsL
|
||||
DefaultMultiSelector = func(title string, items []ModelItem, preChecked []string) ([]string, error) {
|
||||
return []string{"missing-local-a", "missing-local-b"}, nil
|
||||
}
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
if prompt == "Download missing-local-a?" || prompt == "Download missing-local-b?" {
|
||||
return false, nil
|
||||
}
|
||||
@@ -1180,7 +1671,7 @@ func TestLaunchIntegration_ConfiguredEditorLaunchValidatesPrimaryOnly(t *testing
|
||||
t.Fatalf("failed to seed config: %v", err)
|
||||
}
|
||||
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
t.Fatalf("did not expect prompt during normal configured launch: %q", prompt)
|
||||
return false, nil
|
||||
}
|
||||
@@ -1245,7 +1736,7 @@ func TestLaunchIntegration_ConfiguredEditorLaunchSkipsReconfigure(t *testing.T)
|
||||
t.Fatalf("failed to seed config: %v", err)
|
||||
}
|
||||
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
t.Fatalf("did not expect prompt during a normal editor launch: %s", prompt)
|
||||
return false, nil
|
||||
}
|
||||
@@ -1410,7 +1901,7 @@ func TestLaunchIntegration_ConfigureOnlyDoesNotRequireInstalledBinary(t *testing
|
||||
}
|
||||
|
||||
var prompts []string
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
prompts = append(prompts, prompt)
|
||||
if strings.Contains(prompt, "Launch LauncherEditor now?") {
|
||||
return false, nil
|
||||
@@ -1569,7 +2060,7 @@ func TestLaunchIntegration_ClaudeForceConfigureMissingSelectionDoesNotSave(t *te
|
||||
DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) {
|
||||
return "missing-model", nil
|
||||
}
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
if prompt == "Download missing-model?" {
|
||||
return false, nil
|
||||
}
|
||||
@@ -1631,7 +2122,7 @@ func TestLaunchIntegration_ClaudeModelOverrideSkipsSelector(t *testing.T) {
|
||||
}
|
||||
|
||||
var confirmCalls int
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
confirmCalls++
|
||||
if !strings.Contains(prompt, "glm-4") {
|
||||
t.Fatalf("expected download prompt for override model, got %q", prompt)
|
||||
@@ -1695,7 +2186,7 @@ func TestLaunchIntegration_ConfigureOnlyPrompt(t *testing.T) {
|
||||
}
|
||||
|
||||
var prompts []string
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
prompts = append(prompts, prompt)
|
||||
if strings.Contains(prompt, "Launch StubSingle now?") {
|
||||
return false, nil
|
||||
@@ -1745,7 +2236,7 @@ func TestLaunchIntegration_ModelOverrideHeadlessMissingFailsWithoutPrompt(t *tes
|
||||
withIntegrationOverride(t, "droid", runner)
|
||||
|
||||
confirmCalled := false
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
confirmCalled = true
|
||||
return true, nil
|
||||
}
|
||||
@@ -1802,7 +2293,7 @@ func TestLaunchIntegration_ModelOverrideHeadlessCanOverrideMissingModelPolicy(t
|
||||
withIntegrationOverride(t, "droid", runner)
|
||||
|
||||
confirmCalled := false
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
confirmCalled = true
|
||||
if !strings.Contains(prompt, "missing-model") {
|
||||
t.Fatalf("expected prompt to mention missing model, got %q", prompt)
|
||||
@@ -1860,7 +2351,7 @@ func TestLaunchIntegration_ModelOverrideInteractiveMissingPromptsAndPulls(t *tes
|
||||
withIntegrationOverride(t, "droid", runner)
|
||||
|
||||
confirmCalled := false
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
confirmCalled = true
|
||||
if !strings.Contains(prompt, "missing-model") {
|
||||
t.Fatalf("expected prompt to mention missing model, got %q", prompt)
|
||||
@@ -1921,7 +2412,7 @@ func TestLaunchIntegration_HeadlessSelectorFlowFailsWithoutPrompt(t *testing.T)
|
||||
}
|
||||
|
||||
confirmCalled := false
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
confirmCalled = true
|
||||
return true, nil
|
||||
}
|
||||
|
||||
+34
-15
@@ -23,15 +23,15 @@ import (
|
||||
var recommendedModels = []ModelItem{
|
||||
{Name: "kimi-k2.5:cloud", Description: "Multimodal reasoning with subagents", Recommended: true},
|
||||
{Name: "qwen3.5:cloud", Description: "Reasoning, coding, and agentic tool use with vision", Recommended: true},
|
||||
{Name: "glm-5:cloud", Description: "Reasoning and code generation", Recommended: true},
|
||||
{Name: "glm-5.1:cloud", Description: "Reasoning and code generation", Recommended: true},
|
||||
{Name: "minimax-m2.7:cloud", Description: "Fast, efficient coding and real-world productivity", Recommended: true},
|
||||
{Name: "glm-4.7-flash", Description: "Reasoning and code generation locally", Recommended: true},
|
||||
{Name: "gemma4", Description: "Reasoning and code generation locally", Recommended: true},
|
||||
{Name: "qwen3.5", Description: "Reasoning, coding, and visual understanding locally", Recommended: true},
|
||||
}
|
||||
|
||||
var recommendedVRAM = map[string]string{
|
||||
"glm-4.7-flash": "~25GB",
|
||||
"qwen3.5": "~11GB",
|
||||
"gemma4": "~16GB",
|
||||
"qwen3.5": "~11GB",
|
||||
}
|
||||
|
||||
// cloudModelLimit holds context and output token limits for a cloud model.
|
||||
@@ -47,9 +47,11 @@ var cloudModelLimits = map[string]cloudModelLimit{
|
||||
"cogito-2.1:671b": {Context: 163_840, Output: 65_536},
|
||||
"deepseek-v3.1:671b": {Context: 163_840, Output: 163_840},
|
||||
"deepseek-v3.2": {Context: 163_840, Output: 65_536},
|
||||
"gemma4:31b": {Context: 262_144, Output: 131_072},
|
||||
"glm-4.6": {Context: 202_752, Output: 131_072},
|
||||
"glm-4.7": {Context: 202_752, Output: 131_072},
|
||||
"glm-5": {Context: 202_752, Output: 131_072},
|
||||
"glm-5.1": {Context: 202_752, Output: 131_072},
|
||||
"gpt-oss:120b": {Context: 131_072, Output: 131_072},
|
||||
"gpt-oss:20b": {Context: 131_072, Output: 131_072},
|
||||
"kimi-k2:1t": {Context: 262_144, Output: 262_144},
|
||||
@@ -228,7 +230,7 @@ func pullMissingModel(ctx context.Context, client *api.Client, model string) err
|
||||
|
||||
// prepareEditorIntegration persists models and applies editor-managed config files.
|
||||
func prepareEditorIntegration(name string, runner Runner, editor Editor, models []string) error {
|
||||
if ok, err := confirmEditorEdit(runner, editor); err != nil {
|
||||
if ok, err := confirmConfigEdit(runner, editor.Paths()); err != nil {
|
||||
return err
|
||||
} else if !ok {
|
||||
return errCancelled
|
||||
@@ -242,8 +244,22 @@ func prepareEditorIntegration(name string, runner Runner, editor Editor, models
|
||||
return nil
|
||||
}
|
||||
|
||||
func confirmEditorEdit(runner Runner, editor Editor) (bool, error) {
|
||||
paths := editor.Paths()
|
||||
func prepareManagedSingleIntegration(name string, runner Runner, managed ManagedSingleModel, model string) error {
|
||||
if ok, err := confirmConfigEdit(runner, managed.Paths()); err != nil {
|
||||
return err
|
||||
} else if !ok {
|
||||
return errCancelled
|
||||
}
|
||||
if err := managed.Configure(model); err != nil {
|
||||
return fmt.Errorf("setup failed: %w", err)
|
||||
}
|
||||
if err := config.SaveIntegration(name, []string{model}); err != nil {
|
||||
return fmt.Errorf("failed to save: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func confirmConfigEdit(runner Runner, paths []string) (bool, error) {
|
||||
if len(paths) == 0 {
|
||||
return true, nil
|
||||
}
|
||||
@@ -343,8 +359,6 @@ func buildModelList(existing []modelInfo, preChecked []string, current string) (
|
||||
recRank[rec.Name] = i + 1
|
||||
}
|
||||
|
||||
onlyLocal := hasLocalModel && !hasCloudModel
|
||||
|
||||
if hasLocalModel || hasCloudModel {
|
||||
slices.SortStableFunc(items, func(a, b ModelItem) int {
|
||||
ac, bc := checked[a.Name], checked[b.Name]
|
||||
@@ -366,12 +380,6 @@ func buildModelList(existing []modelInfo, preChecked []string, current string) (
|
||||
}
|
||||
if aRec && bRec {
|
||||
if aCloud != bCloud {
|
||||
if onlyLocal {
|
||||
if aCloud {
|
||||
return 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
if aCloud {
|
||||
return -1
|
||||
}
|
||||
@@ -379,6 +387,17 @@ func buildModelList(existing []modelInfo, preChecked []string, current string) (
|
||||
}
|
||||
return recRank[a.Name] - recRank[b.Name]
|
||||
}
|
||||
// Among checked non-recommended items - put the default first
|
||||
if ac && !aRec && current != "" {
|
||||
aCurrent := a.Name == current
|
||||
bCurrent := b.Name == current
|
||||
if aCurrent != bCurrent {
|
||||
if aCurrent {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
}
|
||||
if aNew != bNew {
|
||||
if aNew {
|
||||
return 1
|
||||
|
||||
+118
-11
@@ -102,8 +102,6 @@ func (c *Openclaw) Run(model string, args []string) error {
|
||||
registerWebSearchPlugin()
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\n%sStarting your assistant — this may take a moment...%s\n\n", ansiGray, ansiReset)
|
||||
|
||||
// When extra args are passed through, run exactly what the user asked for
|
||||
// after setup and skip the built-in gateway+TUI convenience flow.
|
||||
if len(args) > 0 {
|
||||
@@ -118,6 +116,15 @@ func (c *Openclaw) Run(model string, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := c.runChannelSetupPreflight(bin); err != nil {
|
||||
return err
|
||||
}
|
||||
// Keep local pairing scopes up to date before the gateway lifecycle
|
||||
// (restart/start) regardless of channel preflight branch behavior.
|
||||
patchDeviceScopes()
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\n%sStarting your assistant — this may take a moment...%s\n\n", ansiGray, ansiReset)
|
||||
|
||||
token, port := c.gatewayInfo()
|
||||
addr := fmt.Sprintf("localhost:%d", port)
|
||||
|
||||
@@ -172,6 +179,94 @@ func (c *Openclaw) Run(model string, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// runChannelSetupPreflight prompts users to connect a messaging channel before
|
||||
// starting the built-in gateway+TUI flow. In interactive sessions, it loops
|
||||
// until a channel is configured, unless the user chooses "Set up later".
|
||||
func (c *Openclaw) runChannelSetupPreflight(bin string) error {
|
||||
if !isInteractiveSession() {
|
||||
return nil
|
||||
}
|
||||
// --yes is headless; channel setup spawns an interactive picker we can't
|
||||
// auto-answer, so skip it. Users can run `openclaw channels add` later.
|
||||
if currentLaunchConfirmPolicy.yes {
|
||||
return nil
|
||||
}
|
||||
|
||||
for {
|
||||
if c.channelsConfigured() {
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\nYour assistant can message you on WhatsApp, Telegram, Discord, and more.\n\n")
|
||||
ok, err := ConfirmPromptWithOptions("Connect a channel (messaging app) now?", ConfirmOptions{
|
||||
YesLabel: "Yes",
|
||||
NoLabel: "Set up later",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := exec.Command(bin, "channels", "add")
|
||||
cmd.Env = openclawEnv()
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return windowsHint(fmt.Errorf("openclaw channel setup failed: %w\n\nTry running: %s channels add", err, bin))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// channelsConfigured reports whether local OpenClaw config contains at least
|
||||
// one meaningfully configured channel entry.
|
||||
func (c *Openclaw) channelsConfigured() bool {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, path := range []string{
|
||||
filepath.Join(home, ".openclaw", "openclaw.json"),
|
||||
filepath.Join(home, ".clawdbot", "clawdbot.json"),
|
||||
} {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var cfg map[string]any
|
||||
if json.Unmarshal(data, &cfg) != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
channels, _ := cfg["channels"].(map[string]any)
|
||||
if channels == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for key, value := range channels {
|
||||
if key == "defaults" || key == "modelByChannel" {
|
||||
continue
|
||||
}
|
||||
entry, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for entryKey := range entry {
|
||||
if entryKey != "enabled" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// gatewayInfo reads the gateway auth token and port from the OpenClaw config.
|
||||
func (c *Openclaw) gatewayInfo() (token string, port int) {
|
||||
port = defaultGatewayPort
|
||||
@@ -218,12 +313,9 @@ func printOpenclawReady(bin, token string, port int, firstLaunch bool) {
|
||||
if firstLaunch {
|
||||
fmt.Fprintf(os.Stderr, "%s Quick start:%s\n", ansiBold, ansiReset)
|
||||
fmt.Fprintf(os.Stderr, "%s /help see all commands%s\n", ansiGray, ansiReset)
|
||||
fmt.Fprintf(os.Stderr, "%s %s configure --section channels connect WhatsApp, Telegram, etc.%s\n", ansiGray, bin, ansiReset)
|
||||
fmt.Fprintf(os.Stderr, "%s %s skills browse and install skills%s\n\n", ansiGray, bin, ansiReset)
|
||||
fmt.Fprintf(os.Stderr, "%s The OpenClaw gateway is running in the background.%s\n", ansiYellow, ansiReset)
|
||||
fmt.Fprintf(os.Stderr, "%s Stop it with: %s gateway stop%s\n\n", ansiYellow, bin, ansiReset)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "%sTip: connect WhatsApp, Telegram, and more with: %s configure --section channels%s\n", ansiGray, bin, ansiReset)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,9 +404,10 @@ func (c *Openclaw) onboarded() bool {
|
||||
return lastRunAt != ""
|
||||
}
|
||||
|
||||
// patchDeviceScopes upgrades the local CLI device's paired scopes to include
|
||||
// operator.admin. Only patches the local device, not remote ones.
|
||||
// Best-effort: silently returns on any error.
|
||||
// patchDeviceScopes upgrades the local CLI device's paired operator scopes so
|
||||
// newer gateway auth baselines (approvedScopes) allow launch+TUI reconnects
|
||||
// without forcing an interactive re-pair. Only patches the local device,
|
||||
// not remote ones. Best-effort: silently returns on any error.
|
||||
func patchDeviceScopes() {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
@@ -350,9 +443,15 @@ func patchDeviceScopes() {
|
||||
}
|
||||
|
||||
changed := patchScopes(dev, "scopes", required)
|
||||
if patchScopes(dev, "approvedScopes", required) {
|
||||
changed = true
|
||||
}
|
||||
if tokens, ok := dev["tokens"].(map[string]any); ok {
|
||||
for _, tok := range tokens {
|
||||
for role, tok := range tokens {
|
||||
if tokenMap, ok := tok.(map[string]any); ok {
|
||||
if !isOperatorToken(role, tokenMap) {
|
||||
continue
|
||||
}
|
||||
if patchScopes(tokenMap, "scopes", required) {
|
||||
changed = true
|
||||
}
|
||||
@@ -408,6 +507,14 @@ func patchScopes(obj map[string]any, key string, required []string) bool {
|
||||
return added
|
||||
}
|
||||
|
||||
func isOperatorToken(tokenRole string, token map[string]any) bool {
|
||||
if strings.EqualFold(strings.TrimSpace(tokenRole), "operator") {
|
||||
return true
|
||||
}
|
||||
role, _ := token["role"].(string)
|
||||
return strings.EqualFold(strings.TrimSpace(role), "operator")
|
||||
}
|
||||
|
||||
// canInstallDaemon reports whether the openclaw daemon can be installed as a
|
||||
// background service. Returns false on Linux when systemd is absent (e.g.
|
||||
// containers) so that --install-daemon is omitted and the gateway is started
|
||||
@@ -445,7 +552,7 @@ func ensureOpenclawInstalled() (string, error) {
|
||||
if gitErr != nil {
|
||||
missing = append(missing, "git: https://git-scm.com/")
|
||||
}
|
||||
return "", fmt.Errorf("openclaw is not installed and required dependencies are missing\n\nInstall the following first:\n %s", strings.Join(missing, "\n "))
|
||||
return "", fmt.Errorf("OpenClaw is not installed and required dependencies are missing\n\nInstall the following first:\n %s\n\nThen re-run:\n ollama launch openclaw", strings.Join(missing, "\n "))
|
||||
}
|
||||
|
||||
ok, err := ConfirmPrompt("OpenClaw is not installed. Install with npm?")
|
||||
@@ -678,7 +785,7 @@ func ensureWebSearchPlugin() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%s ✓ Installed web search plugin%s\n", ansiGreen, ansiReset)
|
||||
fmt.Fprintf(os.Stderr, "%s ✓ Installed Ollama web search %s\n", ansiGreen, ansiReset)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
+731
-5
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -64,6 +65,17 @@ func TestOpenclawRunPassthroughArgs(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oldInteractive := isInteractiveSession
|
||||
isInteractiveSession = func() bool { return true }
|
||||
defer func() { isInteractiveSession = oldInteractive }()
|
||||
|
||||
oldConfirmPrompt := DefaultConfirmPrompt
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
t.Fatalf("did not expect confirmation prompt during passthrough launch: %s", prompt)
|
||||
return false, nil
|
||||
}
|
||||
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
|
||||
|
||||
c := &Openclaw{}
|
||||
if err := c.Run("llama3.2", []string{"gateway", "--someflag"}); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
@@ -82,6 +94,163 @@ func TestOpenclawRunPassthroughArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenclawRun_ChannelSetupHappensBeforeGatewayRestart(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses a POSIX shell test binary")
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
t.Setenv("PATH", tmpDir)
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
|
||||
configDir := filepath.Join(tmpDir, ".openclaw")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(fmt.Sprintf(`{
|
||||
"wizard": {"lastRunAt": "2026-01-01T00:00:00Z"},
|
||||
"gateway": {"port": %d}
|
||||
}`, port)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
bin := filepath.Join(tmpDir, "openclaw")
|
||||
script := fmt.Sprintf(`#!/bin/sh
|
||||
printf '%%s\n' "$*" >> "$HOME/invocations.log"
|
||||
if [ "$1" = "channels" ] && [ "$2" = "add" ]; then
|
||||
/bin/mkdir -p "$HOME/.openclaw"
|
||||
/bin/cat > "$HOME/.openclaw/openclaw.json" <<'EOF'
|
||||
{"wizard":{"lastRunAt":"2026-01-01T00:00:00Z"},"gateway":{"port":%d},"channels":{"telegram":{"botToken":"configured"}}}
|
||||
EOF
|
||||
fi
|
||||
`, port)
|
||||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oldInteractive := isInteractiveSession
|
||||
isInteractiveSession = func() bool { return true }
|
||||
defer func() { isInteractiveSession = oldInteractive }()
|
||||
|
||||
promptCount := 0
|
||||
oldConfirmPrompt := DefaultConfirmPrompt
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
promptCount++
|
||||
if prompt != "Connect a channel (messaging app) now?" {
|
||||
t.Fatalf("unexpected prompt: %q", prompt)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
|
||||
|
||||
c := &Openclaw{}
|
||||
if err := c.Run("llama3.2", nil); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
if promptCount != 1 {
|
||||
t.Fatalf("expected one channel setup prompt, got %d", promptCount)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "invocations.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
if len(lines) < 3 {
|
||||
t.Fatalf("expected at least 3 invocations (channels add, daemon restart, tui), got %v", lines)
|
||||
}
|
||||
if lines[0] != "channels add" {
|
||||
t.Fatalf("expected first invocation to be channels setup, got %q", lines[0])
|
||||
}
|
||||
if lines[1] != "daemon restart" {
|
||||
t.Fatalf("expected second invocation to be daemon restart, got %q", lines[1])
|
||||
}
|
||||
if lines[2] != "tui" {
|
||||
t.Fatalf("expected third invocation to be tui, got %q", lines[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenclawRun_SetupLaterContinuesToGatewayAndTUI(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses a POSIX shell test binary")
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
t.Setenv("PATH", tmpDir)
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
|
||||
configDir := filepath.Join(tmpDir, ".openclaw")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(fmt.Sprintf(`{
|
||||
"wizard": {"lastRunAt": "2026-01-01T00:00:00Z"},
|
||||
"gateway": {"port": %d}
|
||||
}`, port)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
bin := filepath.Join(tmpDir, "openclaw")
|
||||
if err := os.WriteFile(bin, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$HOME/invocations.log\"\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oldInteractive := isInteractiveSession
|
||||
isInteractiveSession = func() bool { return true }
|
||||
defer func() { isInteractiveSession = oldInteractive }()
|
||||
|
||||
promptCount := 0
|
||||
oldConfirmPrompt := DefaultConfirmPrompt
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
promptCount++
|
||||
return false, nil
|
||||
}
|
||||
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
|
||||
|
||||
c := &Openclaw{}
|
||||
if err := c.Run("llama3.2", nil); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
if promptCount != 1 {
|
||||
t.Fatalf("expected one channel setup prompt, got %d", promptCount)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "invocations.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
if len(lines) < 2 {
|
||||
t.Fatalf("expected at least 2 invocations (daemon restart, tui), got %v", lines)
|
||||
}
|
||||
if lines[0] != "daemon restart" {
|
||||
t.Fatalf("expected first invocation to be daemon restart, got %q", lines[0])
|
||||
}
|
||||
if lines[1] != "tui" {
|
||||
t.Fatalf("expected second invocation to be tui, got %q", lines[1])
|
||||
}
|
||||
for _, line := range lines {
|
||||
if line == "channels add" {
|
||||
t.Fatalf("did not expect channels add invocation after choosing set up later, got %v", lines)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenclawEdit(t *testing.T) {
|
||||
c := &Openclaw{}
|
||||
tmpDir := t.TempDir()
|
||||
@@ -930,6 +1099,430 @@ func TestOpenclawOnboarded(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpenclawChannelsConfigured(t *testing.T) {
|
||||
c := &Openclaw{}
|
||||
|
||||
t.Run("returns false when no config exists", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
if c.channelsConfigured() {
|
||||
t.Error("expected false when no config exists")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns false for corrupted json", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
configDir := filepath.Join(tmpDir, ".openclaw")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{bad`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if c.channelsConfigured() {
|
||||
t.Error("expected false for corrupted config")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns false when channels section is missing", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
configDir := filepath.Join(tmpDir, ".openclaw")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{"theme":"dark"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if c.channelsConfigured() {
|
||||
t.Error("expected false when channels section is missing")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns false for channels defaults and modelByChannel only", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
configDir := filepath.Join(tmpDir, ".openclaw")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{
|
||||
"channels": {
|
||||
"defaults": {"dmPolicy": "pairing"},
|
||||
"modelByChannel": {"telegram": "ollama/llama3.2"}
|
||||
}
|
||||
}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if c.channelsConfigured() {
|
||||
t.Error("expected false for channels metadata only")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns false when channel entry only has enabled", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
configDir := filepath.Join(tmpDir, ".openclaw")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{
|
||||
"channels": {
|
||||
"telegram": {"enabled": true}
|
||||
}
|
||||
}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if c.channelsConfigured() {
|
||||
t.Error("expected false when channel config only has enabled")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns true when a channel has meaningful configuration", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
configDir := filepath.Join(tmpDir, ".openclaw")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{
|
||||
"channels": {
|
||||
"telegram": {"botToken": "secret"}
|
||||
}
|
||||
}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !c.channelsConfigured() {
|
||||
t.Error("expected true when channel has meaningful config")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("prefers new path over legacy", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
newDir := filepath.Join(tmpDir, ".openclaw")
|
||||
legacyDir := filepath.Join(tmpDir, ".clawdbot")
|
||||
if err := os.MkdirAll(newDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(legacyDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(newDir, "openclaw.json"), []byte(`{"channels":{"telegram":{"enabled":true}}}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(legacyDir, "clawdbot.json"), []byte(`{"channels":{"telegram":{"botToken":"configured"}}}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if c.channelsConfigured() {
|
||||
t.Error("expected false because new config should take precedence")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpenclawChannelSetupPreflight(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses a POSIX shell test binary")
|
||||
}
|
||||
|
||||
c := &Openclaw{}
|
||||
|
||||
t.Run("skips in non-interactive sessions", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
t.Setenv("PATH", tmpDir)
|
||||
configDir := filepath.Join(tmpDir, ".openclaw")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := filepath.Join(tmpDir, "openclaw")
|
||||
if err := os.WriteFile(bin, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$HOME/invocations.log\"\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oldInteractive := isInteractiveSession
|
||||
isInteractiveSession = func() bool { return false }
|
||||
defer func() { isInteractiveSession = oldInteractive }()
|
||||
|
||||
oldConfirmPrompt := DefaultConfirmPrompt
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
t.Fatalf("did not expect prompt in non-interactive mode: %s", prompt)
|
||||
return false, nil
|
||||
}
|
||||
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
|
||||
|
||||
if err := c.runChannelSetupPreflight("openclaw"); err != nil {
|
||||
t.Fatalf("runChannelSetupPreflight() error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "invocations.log")); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected no command invocation in non-interactive mode, got err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("already configured does not prompt or run channels add", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
t.Setenv("PATH", tmpDir)
|
||||
configDir := filepath.Join(tmpDir, ".openclaw")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{"channels":{"telegram":{"botToken":"set"}}}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := filepath.Join(tmpDir, "openclaw")
|
||||
if err := os.WriteFile(bin, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$HOME/invocations.log\"\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oldInteractive := isInteractiveSession
|
||||
isInteractiveSession = func() bool { return true }
|
||||
defer func() { isInteractiveSession = oldInteractive }()
|
||||
|
||||
oldConfirmPrompt := DefaultConfirmPrompt
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
t.Fatalf("did not expect prompt when already configured: %s", prompt)
|
||||
return false, nil
|
||||
}
|
||||
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
|
||||
|
||||
if err := c.runChannelSetupPreflight("openclaw"); err != nil {
|
||||
t.Fatalf("runChannelSetupPreflight() error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "invocations.log")); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected no channels add invocation, got err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("--yes skips preflight without channels configured", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
t.Setenv("PATH", tmpDir)
|
||||
configDir := filepath.Join(tmpDir, ".openclaw")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Empty config = no channels configured. Without the --yes skip, the
|
||||
// preflight would prompt and (on confirm) spawn `openclaw channels add`.
|
||||
if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := filepath.Join(tmpDir, "openclaw")
|
||||
if err := os.WriteFile(bin, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$HOME/invocations.log\"\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oldInteractive := isInteractiveSession
|
||||
isInteractiveSession = func() bool { return true }
|
||||
defer func() { isInteractiveSession = oldInteractive }()
|
||||
|
||||
restore := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true})
|
||||
defer restore()
|
||||
|
||||
oldConfirmPrompt := DefaultConfirmPrompt
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
t.Fatalf("did not expect prompt in --yes mode: %s", prompt)
|
||||
return false, nil
|
||||
}
|
||||
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
|
||||
|
||||
if err := c.runChannelSetupPreflight("openclaw"); err != nil {
|
||||
t.Fatalf("runChannelSetupPreflight() error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "invocations.log")); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected no channels add invocation in --yes mode, got err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("set up later prompts once and exits", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
t.Setenv("PATH", tmpDir)
|
||||
configDir := filepath.Join(tmpDir, ".openclaw")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := filepath.Join(tmpDir, "openclaw")
|
||||
if err := os.WriteFile(bin, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$HOME/invocations.log\"\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oldInteractive := isInteractiveSession
|
||||
isInteractiveSession = func() bool { return true }
|
||||
defer func() { isInteractiveSession = oldInteractive }()
|
||||
|
||||
promptCount := 0
|
||||
oldConfirmPrompt := DefaultConfirmPrompt
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
promptCount++
|
||||
return false, nil
|
||||
}
|
||||
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
|
||||
|
||||
if err := c.runChannelSetupPreflight("openclaw"); err != nil {
|
||||
t.Fatalf("runChannelSetupPreflight() error = %v", err)
|
||||
}
|
||||
if promptCount != 1 {
|
||||
t.Fatalf("expected 1 prompt, got %d", promptCount)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "invocations.log")); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected no channels add invocation, got err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("yes runs channels add and exits after configuration", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
t.Setenv("PATH", tmpDir)
|
||||
configDir := filepath.Join(tmpDir, ".openclaw")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := filepath.Join(tmpDir, "openclaw")
|
||||
script := `#!/bin/sh
|
||||
printf '%s\n' "$*" >> "$HOME/invocations.log"
|
||||
if [ "$1" = "channels" ] && [ "$2" = "add" ]; then
|
||||
/bin/mkdir -p "$HOME/.openclaw"
|
||||
/bin/cat > "$HOME/.openclaw/openclaw.json" <<'EOF'
|
||||
{"channels":{"telegram":{"botToken":"configured"}}}
|
||||
EOF
|
||||
fi
|
||||
`
|
||||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oldInteractive := isInteractiveSession
|
||||
isInteractiveSession = func() bool { return true }
|
||||
defer func() { isInteractiveSession = oldInteractive }()
|
||||
|
||||
promptCount := 0
|
||||
oldConfirmPrompt := DefaultConfirmPrompt
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
promptCount++
|
||||
return true, nil
|
||||
}
|
||||
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
|
||||
|
||||
if err := c.runChannelSetupPreflight("openclaw"); err != nil {
|
||||
t.Fatalf("runChannelSetupPreflight() error = %v", err)
|
||||
}
|
||||
if promptCount != 1 {
|
||||
t.Fatalf("expected 1 prompt, got %d", promptCount)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "invocations.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
if len(lines) != 1 || lines[0] != "channels add" {
|
||||
t.Fatalf("expected one 'channels add' invocation, got %v", lines)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("re-prompts when channels add does not configure anything", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
t.Setenv("PATH", tmpDir)
|
||||
configDir := filepath.Join(tmpDir, ".openclaw")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := filepath.Join(tmpDir, "openclaw")
|
||||
if err := os.WriteFile(bin, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$HOME/invocations.log\"\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oldInteractive := isInteractiveSession
|
||||
isInteractiveSession = func() bool { return true }
|
||||
defer func() { isInteractiveSession = oldInteractive }()
|
||||
|
||||
promptCount := 0
|
||||
oldConfirmPrompt := DefaultConfirmPrompt
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
promptCount++
|
||||
return promptCount == 1, nil
|
||||
}
|
||||
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
|
||||
|
||||
if err := c.runChannelSetupPreflight("openclaw"); err != nil {
|
||||
t.Fatalf("runChannelSetupPreflight() error = %v", err)
|
||||
}
|
||||
if promptCount != 2 {
|
||||
t.Fatalf("expected 2 prompts, got %d", promptCount)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "invocations.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
if len(lines) != 1 || lines[0] != "channels add" {
|
||||
t.Fatalf("expected one 'channels add' invocation, got %v", lines)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns actionable error when channels add fails", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
t.Setenv("PATH", tmpDir)
|
||||
configDir := filepath.Join(tmpDir, ".openclaw")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := filepath.Join(tmpDir, "openclaw")
|
||||
script := `#!/bin/sh
|
||||
if [ "$1" = "channels" ] && [ "$2" = "add" ]; then
|
||||
exit 42
|
||||
fi
|
||||
`
|
||||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oldInteractive := isInteractiveSession
|
||||
isInteractiveSession = func() bool { return true }
|
||||
defer func() { isInteractiveSession = oldInteractive }()
|
||||
|
||||
oldConfirmPrompt := DefaultConfirmPrompt
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
|
||||
|
||||
err := c.runChannelSetupPreflight("openclaw")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when channels add fails")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Try running: openclaw channels add") {
|
||||
t.Fatalf("expected actionable remediation hint, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpenclawGatewayInfo(t *testing.T) {
|
||||
c := &Openclaw{}
|
||||
|
||||
@@ -1036,6 +1629,133 @@ func TestOpenclawGatewayInfo(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestPatchDeviceScopes(t *testing.T) {
|
||||
t.Run("patches device approved scopes and operator token only", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
identityDir := filepath.Join(tmpDir, ".openclaw", "identity")
|
||||
if err := os.MkdirAll(identityDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(identityDir, "device-auth.json"), []byte(`{"deviceId":"dev-1"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
devicesDir := filepath.Join(tmpDir, ".openclaw", "devices")
|
||||
if err := os.MkdirAll(devicesDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(devicesDir, "paired.json"), []byte(`{
|
||||
"dev-1": {
|
||||
"deviceId": "dev-1",
|
||||
"scopes": ["operator.read"],
|
||||
"approvedScopes": ["operator.read"],
|
||||
"tokens": {
|
||||
"operator": {"role":"operator","scopes":["operator.read"]},
|
||||
"node": {"role":"node","scopes":["node.exec"]}
|
||||
}
|
||||
}
|
||||
}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
patchDeviceScopes()
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(devicesDir, "paired.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var devices map[string]map[string]any
|
||||
if err := json.Unmarshal(data, &devices); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
required := []string{
|
||||
"operator.read",
|
||||
"operator.admin",
|
||||
"operator.approvals",
|
||||
"operator.pairing",
|
||||
}
|
||||
|
||||
toSet := func(v any) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
items, _ := v.([]any)
|
||||
for _, item := range items {
|
||||
if s, ok := item.(string); ok {
|
||||
out[s] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
assertContainsAll := func(name string, got any, want []string) {
|
||||
t.Helper()
|
||||
set := toSet(got)
|
||||
for _, scope := range want {
|
||||
if !set[scope] {
|
||||
t.Fatalf("%s missing required scope %q (got=%v)", name, scope, set)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dev := devices["dev-1"]
|
||||
assertContainsAll("device.scopes", dev["scopes"], required)
|
||||
assertContainsAll("device.approvedScopes", dev["approvedScopes"], required)
|
||||
|
||||
tokens, _ := dev["tokens"].(map[string]any)
|
||||
operator, _ := tokens["operator"].(map[string]any)
|
||||
assertContainsAll("tokens.operator.scopes", operator["scopes"], required)
|
||||
|
||||
node, _ := tokens["node"].(map[string]any)
|
||||
nodeScopes := toSet(node["scopes"])
|
||||
if len(nodeScopes) != 1 || !nodeScopes["node.exec"] {
|
||||
t.Fatalf("expected non-operator token scopes unchanged, got=%v", nodeScopes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("creates approvedScopes when missing", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
identityDir := filepath.Join(tmpDir, ".openclaw", "identity")
|
||||
if err := os.MkdirAll(identityDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(identityDir, "device-auth.json"), []byte(`{"deviceId":"dev-2"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
devicesDir := filepath.Join(tmpDir, ".openclaw", "devices")
|
||||
if err := os.MkdirAll(devicesDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(devicesDir, "paired.json"), []byte(`{
|
||||
"dev-2": {
|
||||
"deviceId": "dev-2",
|
||||
"scopes": ["operator.read"],
|
||||
"tokens": {"operator":{"role":"operator","scopes":["operator.read"]}}
|
||||
}
|
||||
}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
patchDeviceScopes()
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(devicesDir, "paired.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var devices map[string]map[string]any
|
||||
if err := json.Unmarshal(data, &devices); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dev := devices["dev-2"]
|
||||
if _, ok := dev["approvedScopes"]; !ok {
|
||||
t.Fatal("expected approvedScopes to be created")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPrintOpenclawReady(t *testing.T) {
|
||||
t.Run("includes port in URL", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
@@ -1126,14 +1846,17 @@ func TestPrintOpenclawReady(t *testing.T) {
|
||||
buf.ReadFrom(r)
|
||||
|
||||
output := buf.String()
|
||||
for _, want := range []string{"/help", "channels", "skills", "gateway"} {
|
||||
for _, want := range []string{"/help", "skills", "gateway"} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Errorf("expected %q in first-launch output, got:\n%s", want, output)
|
||||
}
|
||||
}
|
||||
if strings.Contains(output, "configure --section channels") {
|
||||
t.Errorf("did not expect channels configure tip in first-launch output, got:\n%s", output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("subsequent launch shows single tip", func(t *testing.T) {
|
||||
t.Run("subsequent launch omits quick start tips", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
old := os.Stderr
|
||||
r, w, _ := os.Pipe()
|
||||
@@ -1146,12 +1869,15 @@ func TestPrintOpenclawReady(t *testing.T) {
|
||||
buf.ReadFrom(r)
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "Tip:") {
|
||||
t.Errorf("expected single tip line, got:\n%s", output)
|
||||
}
|
||||
if strings.Contains(output, "Quick start") {
|
||||
t.Errorf("should not show quick start on subsequent launch")
|
||||
}
|
||||
if strings.Contains(output, "browse skills with") {
|
||||
t.Errorf("should not show repeated skills tip on subsequent launch")
|
||||
}
|
||||
if strings.Contains(output, "configure --section channels") {
|
||||
t.Errorf("did not expect channels configure tip on subsequent launch, got:\n%s", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+145
-138
@@ -3,50 +3,101 @@ package launch
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
)
|
||||
|
||||
// OpenCode implements Runner and Editor for OpenCode integration
|
||||
type OpenCode struct{}
|
||||
// OpenCode implements Runner and Editor for OpenCode integration.
|
||||
// Config is passed via OPENCODE_CONFIG_CONTENT env var at launch time
|
||||
// instead of writing to opencode's config files.
|
||||
type OpenCode struct {
|
||||
configContent string // JSON config built by Edit, passed to Run via env var
|
||||
}
|
||||
|
||||
func (o *OpenCode) String() string { return "OpenCode" }
|
||||
|
||||
// findOpenCode returns the opencode binary path, checking PATH first then the
|
||||
// curl installer location (~/.opencode/bin) which may not be on PATH yet.
|
||||
func findOpenCode() (string, bool) {
|
||||
if p, err := exec.LookPath("opencode"); err == nil {
|
||||
return p, true
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
name := "opencode"
|
||||
if runtime.GOOS == "windows" {
|
||||
name = "opencode.exe"
|
||||
}
|
||||
fallback := filepath.Join(home, ".opencode", "bin", name)
|
||||
if _, err := os.Stat(fallback); err == nil {
|
||||
return fallback, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (o *OpenCode) Run(model string, args []string) error {
|
||||
if _, err := exec.LookPath("opencode"); err != nil {
|
||||
opencodePath, ok := findOpenCode()
|
||||
if !ok {
|
||||
return fmt.Errorf("opencode is not installed, install from https://opencode.ai")
|
||||
}
|
||||
|
||||
cmd := exec.Command("opencode", args...)
|
||||
cmd := exec.Command(opencodePath, args...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Env = os.Environ()
|
||||
if content := o.resolveContent(model); content != "" {
|
||||
cmd.Env = append(cmd.Env, "OPENCODE_CONFIG_CONTENT="+content)
|
||||
}
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// resolveContent returns the inline config to send via OPENCODE_CONFIG_CONTENT.
|
||||
// Returns content built by Edit if available, otherwise builds from model.json
|
||||
// with the requested model as primary (e.g. re-launch with saved config).
|
||||
func (o *OpenCode) resolveContent(model string) string {
|
||||
if o.configContent != "" {
|
||||
return o.configContent
|
||||
}
|
||||
models := readModelJSONModels()
|
||||
if !slices.Contains(models, model) {
|
||||
models = append([]string{model}, models...)
|
||||
}
|
||||
content, err := buildInlineConfig(model, models)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func (o *OpenCode) Paths() []string {
|
||||
home, err := os.UserHomeDir()
|
||||
sp, err := openCodeStatePath()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var paths []string
|
||||
p := filepath.Join(home, ".config", "opencode", "opencode.json")
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
sp := filepath.Join(home, ".local", "state", "opencode", "model.json")
|
||||
if _, err := os.Stat(sp); err == nil {
|
||||
paths = append(paths, sp)
|
||||
return []string{sp}
|
||||
}
|
||||
return paths
|
||||
return nil
|
||||
}
|
||||
|
||||
// openCodeStatePath returns the path to opencode's model state file.
|
||||
// TODO: this hardcodes the Linux/macOS XDG path. On Windows, opencode stores
|
||||
// state under %LOCALAPPDATA% (or similar) — verify and branch on runtime.GOOS.
|
||||
func openCodeStatePath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".local", "state", "opencode", "model.json"), nil
|
||||
}
|
||||
|
||||
func (o *OpenCode) Edit(modelList []string) error {
|
||||
@@ -54,110 +105,17 @@ func (o *OpenCode) Edit(modelList []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
content, err := buildInlineConfig(modelList[0], modelList)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
o.configContent = content
|
||||
|
||||
configPath := filepath.Join(home, ".config", "opencode", "opencode.json")
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config := make(map[string]any)
|
||||
if data, err := os.ReadFile(configPath); err == nil {
|
||||
_ = json.Unmarshal(data, &config) // Ignore parse errors; treat missing/corrupt files as empty
|
||||
}
|
||||
|
||||
config["$schema"] = "https://opencode.ai/config.json"
|
||||
|
||||
provider, ok := config["provider"].(map[string]any)
|
||||
if !ok {
|
||||
provider = make(map[string]any)
|
||||
}
|
||||
|
||||
ollama, ok := provider["ollama"].(map[string]any)
|
||||
if !ok {
|
||||
ollama = map[string]any{
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "Ollama",
|
||||
"options": map[string]any{
|
||||
"baseURL": envconfig.Host().String() + "/v1",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate legacy provider name
|
||||
if name, _ := ollama["name"].(string); name == "Ollama (local)" {
|
||||
ollama["name"] = "Ollama"
|
||||
}
|
||||
|
||||
models, ok := ollama["models"].(map[string]any)
|
||||
if !ok {
|
||||
models = make(map[string]any)
|
||||
}
|
||||
|
||||
selectedSet := make(map[string]bool)
|
||||
for _, m := range modelList {
|
||||
selectedSet[m] = true
|
||||
}
|
||||
|
||||
for name, cfg := range models {
|
||||
if cfgMap, ok := cfg.(map[string]any); ok {
|
||||
if isOllamaModel(cfgMap) && !selectedSet[name] {
|
||||
delete(models, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, model := range modelList {
|
||||
if existing, ok := models[model].(map[string]any); ok {
|
||||
// migrate existing models without _launch marker
|
||||
if isOllamaModel(existing) {
|
||||
existing["_launch"] = true
|
||||
if name, ok := existing["name"].(string); ok {
|
||||
existing["name"] = strings.TrimSuffix(name, " [Ollama]")
|
||||
}
|
||||
}
|
||||
if isCloudModelName(model) {
|
||||
if l, ok := lookupCloudModelLimit(model); ok {
|
||||
existing["limit"] = map[string]any{
|
||||
"context": l.Context,
|
||||
"output": l.Output,
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
entry := map[string]any{
|
||||
"name": model,
|
||||
"_launch": true,
|
||||
}
|
||||
if isCloudModelName(model) {
|
||||
if l, ok := lookupCloudModelLimit(model); ok {
|
||||
entry["limit"] = map[string]any{
|
||||
"context": l.Context,
|
||||
"output": l.Output,
|
||||
}
|
||||
}
|
||||
}
|
||||
models[model] = entry
|
||||
}
|
||||
|
||||
ollama["models"] = models
|
||||
provider["ollama"] = ollama
|
||||
config["provider"] = provider
|
||||
config["model"] = "ollama/" + modelList[0]
|
||||
|
||||
configData, err := json.MarshalIndent(config, "", " ")
|
||||
// Write model state file so models appear in OpenCode's model picker
|
||||
statePath, err := openCodeStatePath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fileutil.WriteWithBackup(configPath, configData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
statePath := filepath.Join(home, ".local", "state", "opencode", "model.json")
|
||||
if err := os.MkdirAll(filepath.Dir(statePath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -209,33 +167,82 @@ func (o *OpenCode) Edit(modelList []string) error {
|
||||
}
|
||||
|
||||
func (o *OpenCode) Models() []string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
config, err := fileutil.ReadJSON(filepath.Join(home, ".config", "opencode", "opencode.json"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
provider, _ := config["provider"].(map[string]any)
|
||||
ollama, _ := provider["ollama"].(map[string]any)
|
||||
models, _ := ollama["models"].(map[string]any)
|
||||
if len(models) == 0 {
|
||||
return nil
|
||||
}
|
||||
keys := slices.Collect(maps.Keys(models))
|
||||
slices.Sort(keys)
|
||||
return keys
|
||||
return nil
|
||||
}
|
||||
|
||||
// isOllamaModel reports whether a model config entry is managed by us
|
||||
func isOllamaModel(cfg map[string]any) bool {
|
||||
if v, ok := cfg["_launch"].(bool); ok && v {
|
||||
return true
|
||||
// buildInlineConfig produces the JSON string for OPENCODE_CONFIG_CONTENT.
|
||||
// primary is the model to launch with, models is the full list of available models.
|
||||
func buildInlineConfig(primary string, models []string) (string, error) {
|
||||
if primary == "" || len(models) == 0 {
|
||||
return "", fmt.Errorf("buildInlineConfig: primary and models are required")
|
||||
}
|
||||
// previously used [Ollama] as a suffix for the model managed by ollama launch
|
||||
if name, ok := cfg["name"].(string); ok {
|
||||
return strings.HasSuffix(name, "[Ollama]")
|
||||
config := map[string]any{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": map[string]any{
|
||||
"ollama": map[string]any{
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "Ollama",
|
||||
"options": map[string]any{
|
||||
"baseURL": envconfig.Host().String() + "/v1",
|
||||
},
|
||||
"models": buildModelEntries(models),
|
||||
},
|
||||
},
|
||||
"model": "ollama/" + primary,
|
||||
}
|
||||
return false
|
||||
data, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// readModelJSONModels reads ollama model IDs from the opencode model.json state file
|
||||
func readModelJSONModels() []string {
|
||||
statePath, err := openCodeStatePath()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(statePath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var state map[string]any
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return nil
|
||||
}
|
||||
recent, _ := state["recent"].([]any)
|
||||
var models []string
|
||||
for _, entry := range recent {
|
||||
e, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if e["providerID"] != "ollama" {
|
||||
continue
|
||||
}
|
||||
if id, ok := e["modelID"].(string); ok && id != "" {
|
||||
models = append(models, id)
|
||||
}
|
||||
}
|
||||
return models
|
||||
}
|
||||
|
||||
func buildModelEntries(modelList []string) map[string]any {
|
||||
models := make(map[string]any)
|
||||
for _, model := range modelList {
|
||||
entry := map[string]any{
|
||||
"name": model,
|
||||
}
|
||||
if isCloudModelName(model) {
|
||||
if l, ok := lookupCloudModelLimit(model); ok {
|
||||
entry["limit"] = map[string]any{
|
||||
"context": l.Context,
|
||||
"output": l.Output,
|
||||
}
|
||||
}
|
||||
}
|
||||
models[model] = entry
|
||||
}
|
||||
return models
|
||||
}
|
||||
+664
-667
File diff suppressed because it is too large.
Load diff
+2
-2
@@ -53,7 +53,7 @@ func (p *Pi) Run(model string, args []string) error {
|
||||
|
||||
func ensureNpmInstalled() error {
|
||||
if _, err := exec.LookPath("npm"); err != nil {
|
||||
return fmt.Errorf("npm (Node.js) is required to launch pi\n\nInstall it first:\n https://nodejs.org/")
|
||||
return fmt.Errorf("npm (Node.js) is required to launch pi\n\nInstall it first:\n https://nodejs.org/\n\nThen re-run:\n ollama launch pi")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func ensurePiInstalled() (string, error) {
|
||||
}
|
||||
|
||||
if _, err := exec.LookPath("npm"); err != nil {
|
||||
return "", fmt.Errorf("pi is not installed and required dependencies are missing\n\nInstall the following first:\n npm (Node.js): https://nodejs.org/")
|
||||
return "", fmt.Errorf("pi is not installed and required dependencies are missing\n\nInstall the following first:\n npm (Node.js): https://nodejs.org/\n\nThen re-run:\n ollama launch pi")
|
||||
}
|
||||
|
||||
ok, err := ConfirmPrompt("Pi is not installed. Install with npm?")
|
||||
|
||||
@@ -80,7 +80,9 @@ exit 0
|
||||
withConfirm := func(t *testing.T, fn func(prompt string) (bool, error)) {
|
||||
t.Helper()
|
||||
oldConfirm := DefaultConfirmPrompt
|
||||
DefaultConfirmPrompt = fn
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
return fn(prompt)
|
||||
}
|
||||
t.Cleanup(func() { DefaultConfirmPrompt = oldConfirm })
|
||||
}
|
||||
|
||||
|
||||
+32
-5
@@ -33,7 +33,7 @@ type IntegrationInfo struct {
|
||||
Description string
|
||||
}
|
||||
|
||||
var launcherIntegrationOrder = []string{"opencode", "droid", "pi"}
|
||||
var launcherIntegrationOrder = []string{"openclaw", "claude", "opencode", "hermes", "codex", "droid", "pi"}
|
||||
|
||||
var integrationSpecs = []*IntegrationSpec{
|
||||
{
|
||||
@@ -74,6 +74,19 @@ var integrationSpecs = []*IntegrationSpec{
|
||||
Command: []string{"npm", "install", "-g", "@openai/codex"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "copilot",
|
||||
Runner: &Copilot{},
|
||||
Aliases: []string{"copilot-cli"},
|
||||
Description: "GitHub's AI coding agent for the terminal",
|
||||
Install: IntegrationInstallSpec{
|
||||
CheckInstalled: func() bool {
|
||||
_, err := (&Copilot{}).findPath()
|
||||
return err == nil
|
||||
},
|
||||
URL: "https://github.com/features/copilot/cli/",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "droid",
|
||||
Runner: &Droid{},
|
||||
@@ -92,8 +105,8 @@ var integrationSpecs = []*IntegrationSpec{
|
||||
Description: "Anomaly's open-source coding agent",
|
||||
Install: IntegrationInstallSpec{
|
||||
CheckInstalled: func() bool {
|
||||
_, err := exec.LookPath("opencode")
|
||||
return err == nil
|
||||
_, ok := findOpenCode()
|
||||
return ok
|
||||
},
|
||||
URL: "https://opencode.ai",
|
||||
},
|
||||
@@ -136,6 +149,20 @@ var integrationSpecs = []*IntegrationSpec{
|
||||
Command: []string{"npm", "install", "-g", "@mariozechner/pi-coding-agent@latest"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "hermes",
|
||||
Runner: &Hermes{},
|
||||
Description: "Self-improving AI agent built by Nous Research",
|
||||
Install: IntegrationInstallSpec{
|
||||
CheckInstalled: func() bool {
|
||||
return (&Hermes{}).installed()
|
||||
},
|
||||
EnsureInstalled: func() error {
|
||||
return (&Hermes{}).ensureInstalled()
|
||||
},
|
||||
URL: "https://hermes-agent.nousresearch.com/docs/getting-started/installation/",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "vscode",
|
||||
Runner: &VSCode{},
|
||||
@@ -255,10 +282,10 @@ func ListVisibleIntegrationSpecs() []IntegrationSpec {
|
||||
return aRank - bRank
|
||||
}
|
||||
if aRank > 0 {
|
||||
return 1
|
||||
return -1
|
||||
}
|
||||
if bRank > 0 {
|
||||
return -1
|
||||
return 1
|
||||
}
|
||||
return strings.Compare(a.Name, b.Name)
|
||||
})
|
||||
|
||||
@@ -26,7 +26,7 @@ func TestEditorRunsDoNotRewriteConfig(t *testing.T) {
|
||||
binary: "opencode",
|
||||
runner: &OpenCode{},
|
||||
checkPath: func(home string) string {
|
||||
return filepath.Join(home, ".config", "opencode", "opencode.json")
|
||||
return filepath.Join(home, ".local", "state", "opencode", "model.json")
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -25,7 +25,13 @@ var errCancelled = ErrCancelled
|
||||
|
||||
// DefaultConfirmPrompt provides a TUI-based confirmation prompt.
|
||||
// When set, ConfirmPrompt delegates to it instead of using raw terminal I/O.
|
||||
var DefaultConfirmPrompt func(prompt string) (bool, error)
|
||||
var DefaultConfirmPrompt func(prompt string, options ConfirmOptions) (bool, error)
|
||||
|
||||
// ConfirmOptions customizes labels for confirmation prompts.
|
||||
type ConfirmOptions struct {
|
||||
YesLabel string
|
||||
NoLabel string
|
||||
}
|
||||
|
||||
// SingleSelector is a function type for single item selection.
|
||||
// current is the name of the previously selected item to highlight; empty means no pre-selection.
|
||||
@@ -65,6 +71,12 @@ func withLaunchConfirmPolicy(policy launchConfirmPolicy) func() {
|
||||
// Behavior is controlled by currentLaunchConfirmPolicy, typically scoped by
|
||||
// withLaunchConfirmPolicy in LaunchCmd (e.g. auto-approve with --yes).
|
||||
func ConfirmPrompt(prompt string) (bool, error) {
|
||||
return ConfirmPromptWithOptions(prompt, ConfirmOptions{})
|
||||
}
|
||||
|
||||
// ConfirmPromptWithOptions is the shared confirmation gate for launch flows
|
||||
// that need custom yes/no labels in interactive UIs.
|
||||
func ConfirmPromptWithOptions(prompt string, options ConfirmOptions) (bool, error) {
|
||||
if currentLaunchConfirmPolicy.yes {
|
||||
return true, nil
|
||||
}
|
||||
@@ -73,7 +85,7 @@ func ConfirmPrompt(prompt string) (bool, error) {
|
||||
}
|
||||
|
||||
if DefaultConfirmPrompt != nil {
|
||||
return DefaultConfirmPrompt(prompt)
|
||||
return DefaultConfirmPrompt(prompt, options)
|
||||
}
|
||||
|
||||
fd := int(os.Stdin.Fd())
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestWithLaunchConfirmPolicy_ScopesAndRestores(t *testing.T) {
|
||||
|
||||
currentLaunchConfirmPolicy = launchConfirmPolicy{}
|
||||
var hookCalls int
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
hookCalls++
|
||||
return true, nil
|
||||
}
|
||||
@@ -74,3 +74,39 @@ func TestWithLaunchConfirmPolicy_ScopesAndRestores(t *testing.T) {
|
||||
t.Fatalf("expected one hook call after restore, got %d", hookCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmPromptWithOptions_DelegatesToOptionsHook(t *testing.T) {
|
||||
oldPolicy := currentLaunchConfirmPolicy
|
||||
oldHook := DefaultConfirmPrompt
|
||||
t.Cleanup(func() {
|
||||
currentLaunchConfirmPolicy = oldPolicy
|
||||
DefaultConfirmPrompt = oldHook
|
||||
})
|
||||
|
||||
currentLaunchConfirmPolicy = launchConfirmPolicy{}
|
||||
called := false
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
called = true
|
||||
if prompt != "Connect now?" {
|
||||
t.Fatalf("unexpected prompt: %q", prompt)
|
||||
}
|
||||
if options.YesLabel != "Yes" || options.NoLabel != "Set up later" {
|
||||
t.Fatalf("unexpected options: %+v", options)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
ok, err := ConfirmPromptWithOptions("Connect now?", ConfirmOptions{
|
||||
YesLabel: "Yes",
|
||||
NoLabel: "Set up later",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ConfirmPromptWithOptions() error = %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected confirm to return true")
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("expected options hook to be called")
|
||||
}
|
||||
}
|
||||
+39
-15
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/ollama/ollama/cmd/launch"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -18,12 +19,16 @@ var (
|
||||
|
||||
type confirmModel struct {
|
||||
prompt string
|
||||
yesLabel string
|
||||
noLabel string
|
||||
yes bool
|
||||
confirmed bool
|
||||
cancelled bool
|
||||
width int
|
||||
}
|
||||
|
||||
type ConfirmOptions = launch.ConfirmOptions
|
||||
|
||||
func (m confirmModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
@@ -40,22 +45,16 @@ func (m confirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc", "n":
|
||||
case "ctrl+c", "esc":
|
||||
m.cancelled = true
|
||||
return m, tea.Quit
|
||||
case "y":
|
||||
m.yes = true
|
||||
m.confirmed = true
|
||||
return m, tea.Quit
|
||||
case "enter":
|
||||
m.confirmed = true
|
||||
return m, tea.Quit
|
||||
case "left", "h":
|
||||
case "left":
|
||||
m.yes = true
|
||||
case "right", "l":
|
||||
case "right":
|
||||
m.yes = false
|
||||
case "tab":
|
||||
m.yes = !m.yes
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,12 +67,20 @@ func (m confirmModel) View() string {
|
||||
}
|
||||
|
||||
var yesBtn, noBtn string
|
||||
yesLabel := m.yesLabel
|
||||
if yesLabel == "" {
|
||||
yesLabel = "Yes"
|
||||
}
|
||||
noLabel := m.noLabel
|
||||
if noLabel == "" {
|
||||
noLabel = "No"
|
||||
}
|
||||
if m.yes {
|
||||
yesBtn = confirmActiveStyle.Render(" Yes ")
|
||||
noBtn = confirmInactiveStyle.Render(" No ")
|
||||
yesBtn = confirmActiveStyle.Render(" " + yesLabel + " ")
|
||||
noBtn = confirmInactiveStyle.Render(" " + noLabel + " ")
|
||||
} else {
|
||||
yesBtn = confirmInactiveStyle.Render(" Yes ")
|
||||
noBtn = confirmActiveStyle.Render(" No ")
|
||||
yesBtn = confirmInactiveStyle.Render(" " + yesLabel + " ")
|
||||
noBtn = confirmActiveStyle.Render(" " + noLabel + " ")
|
||||
}
|
||||
|
||||
s := selectorTitleStyle.Render(m.prompt) + "\n\n"
|
||||
@@ -89,9 +96,26 @@ func (m confirmModel) View() string {
|
||||
// RunConfirm shows a bubbletea yes/no confirmation prompt.
|
||||
// Returns true if the user confirmed, false if cancelled.
|
||||
func RunConfirm(prompt string) (bool, error) {
|
||||
return RunConfirmWithOptions(prompt, ConfirmOptions{})
|
||||
}
|
||||
|
||||
// RunConfirmWithOptions shows a bubbletea yes/no confirmation prompt with
|
||||
// optional custom button labels.
|
||||
func RunConfirmWithOptions(prompt string, options ConfirmOptions) (bool, error) {
|
||||
yesLabel := options.YesLabel
|
||||
if yesLabel == "" {
|
||||
yesLabel = "Yes"
|
||||
}
|
||||
noLabel := options.NoLabel
|
||||
if noLabel == "" {
|
||||
noLabel = "No"
|
||||
}
|
||||
|
||||
m := confirmModel{
|
||||
prompt: prompt,
|
||||
yes: true, // default to yes
|
||||
prompt: prompt,
|
||||
yesLabel: yesLabel,
|
||||
noLabel: noLabel,
|
||||
yes: true, // default to yes
|
||||
}
|
||||
|
||||
p := tea.NewProgram(m)
|
||||
|
||||
+40
-24
@@ -33,6 +33,22 @@ func TestConfirmModel_View_ContainsButtons(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmModel_View_ContainsCustomButtons(t *testing.T) {
|
||||
m := confirmModel{
|
||||
prompt: "Connect a messaging app now?",
|
||||
yesLabel: "Yes",
|
||||
noLabel: "Set up later",
|
||||
yes: true,
|
||||
}
|
||||
got := m.View()
|
||||
if !strings.Contains(got, "Yes") {
|
||||
t.Error("should contain custom yes button")
|
||||
}
|
||||
if !strings.Contains(got, "Set up later") {
|
||||
t.Error("should contain custom no button")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmModel_View_ContainsHelp(t *testing.T) {
|
||||
m := confirmModel{prompt: "Download?", yes: true}
|
||||
got := m.View()
|
||||
@@ -109,30 +125,33 @@ func TestConfirmModel_CtrlCCancels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmModel_NCancels(t *testing.T) {
|
||||
func TestConfirmModel_NDoesNothing(t *testing.T) {
|
||||
m := confirmModel{prompt: "Download?", yes: true}
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}})
|
||||
fm := updated.(confirmModel)
|
||||
if !fm.cancelled {
|
||||
t.Error("'n' should set cancelled=true")
|
||||
if fm.cancelled {
|
||||
t.Error("'n' should not cancel")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Error("'n' should return tea.Quit")
|
||||
if fm.confirmed {
|
||||
t.Error("'n' should not confirm")
|
||||
}
|
||||
if cmd != nil {
|
||||
t.Error("'n' should not quit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmModel_YConfirmsYes(t *testing.T) {
|
||||
func TestConfirmModel_YDoesNothing(t *testing.T) {
|
||||
m := confirmModel{prompt: "Download?", yes: false}
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}})
|
||||
fm := updated.(confirmModel)
|
||||
if !fm.confirmed {
|
||||
t.Error("'y' should set confirmed=true")
|
||||
if fm.confirmed {
|
||||
t.Error("'y' should not confirm")
|
||||
}
|
||||
if !fm.yes {
|
||||
t.Error("'y' should set yes=true")
|
||||
if fm.yes {
|
||||
t.Error("'y' should not change selection")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Error("'y' should return tea.Quit")
|
||||
if cmd != nil {
|
||||
t.Error("'y' should not quit")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,36 +159,33 @@ func TestConfirmModel_ArrowKeysNavigate(t *testing.T) {
|
||||
m := confirmModel{prompt: "Download?", yes: true}
|
||||
|
||||
// Right moves to No
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}})
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRight})
|
||||
fm := updated.(confirmModel)
|
||||
if fm.yes {
|
||||
t.Error("right/l should move to No")
|
||||
t.Error("right should move to No")
|
||||
}
|
||||
if fm.confirmed || fm.cancelled {
|
||||
t.Error("navigation should not confirm or cancel")
|
||||
}
|
||||
|
||||
// Left moves back to Yes
|
||||
updated, _ = fm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}})
|
||||
updated, _ = fm.Update(tea.KeyMsg{Type: tea.KeyLeft})
|
||||
fm = updated.(confirmModel)
|
||||
if !fm.yes {
|
||||
t.Error("left/h should move to Yes")
|
||||
t.Error("left should move to Yes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmModel_TabToggles(t *testing.T) {
|
||||
func TestConfirmModel_TabDoesNothing(t *testing.T) {
|
||||
m := confirmModel{prompt: "Download?", yes: true}
|
||||
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyTab})
|
||||
fm := updated.(confirmModel)
|
||||
if fm.yes {
|
||||
t.Error("tab should toggle from Yes to No")
|
||||
}
|
||||
|
||||
updated, _ = fm.Update(tea.KeyMsg{Type: tea.KeyTab})
|
||||
fm = updated.(confirmModel)
|
||||
if !fm.yes {
|
||||
t.Error("tab should toggle from No to Yes")
|
||||
t.Error("tab should not change selection")
|
||||
}
|
||||
if fm.confirmed || fm.cancelled {
|
||||
t.Error("tab should not confirm or cancel")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-5
@@ -1,7 +1,6 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -56,7 +55,7 @@ var (
|
||||
const maxSelectorItems = 10
|
||||
|
||||
// ErrCancelled is returned when the user cancels the selection.
|
||||
var ErrCancelled = errors.New("cancelled")
|
||||
var ErrCancelled = launch.ErrCancelled
|
||||
|
||||
type SelectItem struct {
|
||||
Name string
|
||||
@@ -817,14 +816,18 @@ func (m multiSelectorModel) View() string {
|
||||
|
||||
s.WriteString("\n")
|
||||
|
||||
count := m.selectedCount()
|
||||
if !m.multi {
|
||||
if count > 0 {
|
||||
s.WriteString(sectionHeaderStyle.Render(fmt.Sprintf("%d models selected - press tab to edit", count)))
|
||||
s.WriteString("\n\n")
|
||||
}
|
||||
s.WriteString(selectorHelpStyle.Render("↑/↓ navigate • enter select • tab add multiple • ← back"))
|
||||
} else {
|
||||
count := m.selectedCount()
|
||||
if count == 0 {
|
||||
s.WriteString(selectorDescStyle.Render(" Select at least one model."))
|
||||
s.WriteString(sectionHeaderStyle.Render("Select at least one model."))
|
||||
} else {
|
||||
s.WriteString(selectorDescStyle.Render(fmt.Sprintf(" %d selected - press enter to continue", count)))
|
||||
s.WriteString(sectionHeaderStyle.Render(fmt.Sprintf("%d models selected - press enter to continue", count)))
|
||||
}
|
||||
s.WriteString("\n\n")
|
||||
s.WriteString(selectorHelpStyle.Render("↑/↓ navigate • space toggle • tab select single • enter confirm • ← back"))
|
||||
|
||||
+37
-37
@@ -45,21 +45,12 @@ type menuItem struct {
|
||||
isOthers bool
|
||||
}
|
||||
|
||||
var mainMenuItems = []menuItem{
|
||||
{
|
||||
title: "Chat with a model",
|
||||
description: "Start an interactive chat with a model",
|
||||
isRunModel: true,
|
||||
},
|
||||
{
|
||||
integration: "claude",
|
||||
},
|
||||
{
|
||||
integration: "codex",
|
||||
},
|
||||
{
|
||||
integration: "openclaw",
|
||||
},
|
||||
const pinnedIntegrationCount = 3
|
||||
|
||||
var runModelMenuItem = menuItem{
|
||||
title: "Chat with a model",
|
||||
description: "Start an interactive chat with a model",
|
||||
isRunModel: true,
|
||||
}
|
||||
|
||||
var othersMenuItem = menuItem{
|
||||
@@ -102,20 +93,14 @@ func shouldExpandOthers(state *launch.LauncherState) bool {
|
||||
}
|
||||
|
||||
func buildMenuItems(state *launch.LauncherState, showOthers bool) []menuItem {
|
||||
items := make([]menuItem, 0, len(mainMenuItems)+1)
|
||||
for _, item := range mainMenuItems {
|
||||
if item.integration == "" {
|
||||
items = append(items, item)
|
||||
continue
|
||||
}
|
||||
if integrationState, ok := state.Integrations[item.integration]; ok {
|
||||
items = append(items, integrationMenuItem(integrationState))
|
||||
}
|
||||
}
|
||||
items := []menuItem{runModelMenuItem}
|
||||
items = append(items, pinnedIntegrationItems(state)...)
|
||||
|
||||
if showOthers {
|
||||
items = append(items, otherIntegrationItems(state)...)
|
||||
} else {
|
||||
otherItems := otherIntegrationItems(state)
|
||||
switch {
|
||||
case showOthers:
|
||||
items = append(items, otherItems...)
|
||||
case len(otherItems) > 0:
|
||||
items = append(items, othersMenuItem)
|
||||
}
|
||||
|
||||
@@ -135,17 +120,28 @@ func integrationMenuItem(state launch.LauncherIntegrationState) menuItem {
|
||||
}
|
||||
|
||||
func otherIntegrationItems(state *launch.LauncherState) []menuItem {
|
||||
pinned := map[string]bool{
|
||||
"claude": true,
|
||||
"codex": true,
|
||||
"openclaw": true,
|
||||
ordered := orderedIntegrationItems(state)
|
||||
if len(ordered) <= pinnedIntegrationCount {
|
||||
return nil
|
||||
}
|
||||
return ordered[pinnedIntegrationCount:]
|
||||
}
|
||||
|
||||
func pinnedIntegrationItems(state *launch.LauncherState) []menuItem {
|
||||
ordered := orderedIntegrationItems(state)
|
||||
if len(ordered) <= pinnedIntegrationCount {
|
||||
return ordered
|
||||
}
|
||||
return ordered[:pinnedIntegrationCount]
|
||||
}
|
||||
|
||||
func orderedIntegrationItems(state *launch.LauncherState) []menuItem {
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var items []menuItem
|
||||
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
|
||||
@@ -155,6 +151,10 @@ func otherIntegrationItems(state *launch.LauncherState) []menuItem {
|
||||
return items
|
||||
}
|
||||
|
||||
func primaryMenuItemCount(state *launch.LauncherState) int {
|
||||
return 1 + len(pinnedIntegrationItems(state))
|
||||
}
|
||||
|
||||
func initialCursor(state *launch.LauncherState, items []menuItem) int {
|
||||
if state == nil || state.LastSelection == "" {
|
||||
return 0
|
||||
@@ -190,7 +190,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
}
|
||||
if m.showOthers && m.cursor < len(mainMenuItems) {
|
||||
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)
|
||||
|
||||
+80
-9
@@ -5,6 +5,7 @@ import (
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/ollama/ollama/cmd/launch"
|
||||
)
|
||||
|
||||
@@ -36,6 +37,20 @@ func launcherTestState() *launch.LauncherState {
|
||||
Changeable: true,
|
||||
AutoInstallable: true,
|
||||
},
|
||||
"opencode": {
|
||||
Name: "opencode",
|
||||
DisplayName: "OpenCode",
|
||||
Description: "Anomaly's open-source coding agent",
|
||||
Selectable: true,
|
||||
Changeable: true,
|
||||
},
|
||||
"hermes": {
|
||||
Name: "hermes",
|
||||
DisplayName: "Hermes Agent",
|
||||
Description: "Self-improving AI agent built by Nous Research",
|
||||
Selectable: true,
|
||||
Changeable: true,
|
||||
},
|
||||
"droid": {
|
||||
Name: "droid",
|
||||
DisplayName: "Droid",
|
||||
@@ -54,30 +69,70 @@ func launcherTestState() *launch.LauncherState {
|
||||
}
|
||||
}
|
||||
|
||||
func findMenuCursorByIntegration(items []menuItem, name string) int {
|
||||
for i, item := range items {
|
||||
if item.integration == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func integrationSequence(items []menuItem) []string {
|
||||
sequence := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
switch {
|
||||
case item.isRunModel:
|
||||
sequence = append(sequence, "run")
|
||||
case item.isOthers:
|
||||
sequence = append(sequence, "more")
|
||||
case item.integration != "":
|
||||
sequence = append(sequence, item.integration)
|
||||
}
|
||||
}
|
||||
return sequence
|
||||
}
|
||||
|
||||
func compareStrings(got, want []string) string {
|
||||
return cmp.Diff(want, got)
|
||||
}
|
||||
|
||||
func TestMenuRendersPinnedItemsAndMore(t *testing.T) {
|
||||
view := newModel(launcherTestState()).View()
|
||||
for _, want := range []string{"Chat with a model", "Launch Claude Code", "Launch Codex", "Launch OpenClaw", "More..."} {
|
||||
menu := newModel(launcherTestState())
|
||||
view := menu.View()
|
||||
for _, want := range []string{"Chat with a model", "Launch OpenClaw", "Launch Claude Code", "Launch OpenCode", "More..."} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Fatalf("expected menu view to contain %q\n%s", want, view)
|
||||
}
|
||||
}
|
||||
if strings.Contains(view, "Launch Codex") {
|
||||
t.Fatalf("expected Codex to be under More, not pinned\n%s", view)
|
||||
}
|
||||
wantOrder := []string{"run", "openclaw", "claude", "opencode", "more"}
|
||||
if diff := compareStrings(integrationSequence(menu.items), wantOrder); diff != "" {
|
||||
t.Fatalf("unexpected pinned order: %s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMenuExpandsOthersFromLastSelection(t *testing.T) {
|
||||
state := launcherTestState()
|
||||
state.LastSelection = "pi"
|
||||
state.LastSelection = "codex"
|
||||
|
||||
menu := newModel(state)
|
||||
if !menu.showOthers {
|
||||
t.Fatal("expected others section to expand when last selection is in the overflow list")
|
||||
}
|
||||
view := menu.View()
|
||||
if !strings.Contains(view, "Launch Pi") {
|
||||
if !strings.Contains(view, "Launch Codex") {
|
||||
t.Fatalf("expected expanded view to contain overflow integration\n%s", view)
|
||||
}
|
||||
if strings.Contains(view, "More...") {
|
||||
t.Fatalf("expected expanded view to replace More... item\n%s", view)
|
||||
}
|
||||
wantOrder := []string{"run", "openclaw", "claude", "opencode", "hermes", "codex", "droid", "pi"}
|
||||
if diff := compareStrings(integrationSequence(menu.items), wantOrder); diff != "" {
|
||||
t.Fatalf("unexpected expanded order: %s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMenuEnterOnRunSelectsRun(t *testing.T) {
|
||||
@@ -102,7 +157,10 @@ func TestMenuRightOnRunSelectsChangeRun(t *testing.T) {
|
||||
|
||||
func TestMenuEnterOnIntegrationSelectsLaunch(t *testing.T) {
|
||||
menu := newModel(launcherTestState())
|
||||
menu.cursor = 1
|
||||
menu.cursor = findMenuCursorByIntegration(menu.items, "claude")
|
||||
if menu.cursor == -1 {
|
||||
t.Fatal("expected claude menu item")
|
||||
}
|
||||
updated, _ := menu.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
got := updated.(model)
|
||||
want := TUIAction{Kind: TUIActionLaunchIntegration, Integration: "claude"}
|
||||
@@ -113,7 +171,10 @@ func TestMenuEnterOnIntegrationSelectsLaunch(t *testing.T) {
|
||||
|
||||
func TestMenuRightOnIntegrationSelectsConfigure(t *testing.T) {
|
||||
menu := newModel(launcherTestState())
|
||||
menu.cursor = 1
|
||||
menu.cursor = findMenuCursorByIntegration(menu.items, "claude")
|
||||
if menu.cursor == -1 {
|
||||
t.Fatal("expected claude menu item")
|
||||
}
|
||||
updated, _ := menu.Update(tea.KeyMsg{Type: tea.KeyRight})
|
||||
got := updated.(model)
|
||||
want := TUIAction{Kind: TUIActionLaunchIntegration, Integration: "claude", ForceConfigure: true}
|
||||
@@ -130,7 +191,10 @@ func TestMenuIgnoresDisabledActions(t *testing.T) {
|
||||
state.Integrations["claude"] = claude
|
||||
|
||||
menu := newModel(state)
|
||||
menu.cursor = 1
|
||||
menu.cursor = findMenuCursorByIntegration(menu.items, "claude")
|
||||
if menu.cursor == -1 {
|
||||
t.Fatal("expected claude menu item")
|
||||
}
|
||||
|
||||
updatedEnter, _ := menu.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if updatedEnter.(model).selected {
|
||||
@@ -150,7 +214,10 @@ func TestMenuShowsCurrentModelSuffixes(t *testing.T) {
|
||||
t.Fatalf("expected run row to show current model suffix\n%s", runView)
|
||||
}
|
||||
|
||||
menu.cursor = 1
|
||||
menu.cursor = findMenuCursorByIntegration(menu.items, "claude")
|
||||
if menu.cursor == -1 {
|
||||
t.Fatal("expected claude menu item")
|
||||
}
|
||||
integrationView := menu.View()
|
||||
if !strings.Contains(integrationView, "(glm-5:cloud)") {
|
||||
t.Fatalf("expected integration row to show current model suffix\n%s", integrationView)
|
||||
@@ -166,8 +233,12 @@ func TestMenuShowsInstallStatusAndHint(t *testing.T) {
|
||||
codex.InstallHint = "Install from https://example.com/codex"
|
||||
state.Integrations["codex"] = codex
|
||||
|
||||
state.LastSelection = "codex"
|
||||
menu := newModel(state)
|
||||
menu.cursor = 2
|
||||
menu.cursor = findMenuCursorByIntegration(menu.items, "codex")
|
||||
if menu.cursor == -1 {
|
||||
t.Fatal("expected codex menu item in overflow section")
|
||||
}
|
||||
view := menu.View()
|
||||
if !strings.Contains(view, "(not installed)") {
|
||||
t.Fatalf("expected not-installed marker\n%s", view)
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@ func GPUDevices(ctx context.Context, runners []ml.FilteredRunnerDiscovery) []ml.
|
||||
}
|
||||
var dirs []string
|
||||
if dir != "" {
|
||||
if requested != "" && filepath.Base(dir) != requested {
|
||||
if requested != "" && !strings.HasPrefix(requested, "mlx_") && filepath.Base(dir) != requested {
|
||||
slog.Debug("skipping available library at user's request", "requested", requested, "libDir", dir)
|
||||
continue
|
||||
} else if jetpack != "" && filepath.Base(dir) != "cuda_"+jetpack {
|
||||
|
||||
+3
-1
@@ -110,7 +110,8 @@
|
||||
"group": "Assistants",
|
||||
"expanded": true,
|
||||
"pages": [
|
||||
"/integrations/openclaw"
|
||||
"/integrations/openclaw",
|
||||
"/integrations/hermes"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -119,6 +120,7 @@
|
||||
"pages": [
|
||||
"/integrations/claude-code",
|
||||
"/integrations/codex",
|
||||
"/integrations/copilot-cli",
|
||||
"/integrations/opencode",
|
||||
"/integrations/droid",
|
||||
"/integrations/goose",
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
title: Copilot CLI
|
||||
---
|
||||
|
||||
GitHub Copilot CLI is GitHub's AI coding agent for the terminal. It can understand your codebase, make edits, run commands, and help you build software faster.
|
||||
|
||||
Open models can be used with Copilot CLI through Ollama, enabling you to use models such as `qwen3.5`, `glm-5.1:cloud`, `kimi-k2.5:cloud`.
|
||||
|
||||
## Install
|
||||
|
||||
Install [Copilot CLI](https://github.com/features/copilot/cli/):
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```shell macOS / Linux (Homebrew)
|
||||
brew install copilot-cli
|
||||
```
|
||||
|
||||
```shell npm (all platforms)
|
||||
npm install -g @github/copilot
|
||||
```
|
||||
|
||||
```shell macOS / Linux (script)
|
||||
curl -fsSL https://gh.io/copilot-install | bash
|
||||
```
|
||||
|
||||
```powershell Windows (WinGet)
|
||||
winget install GitHub.Copilot
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## Usage with Ollama
|
||||
|
||||
### Quick setup
|
||||
|
||||
```shell
|
||||
ollama launch copilot
|
||||
```
|
||||
|
||||
### Run directly with a model
|
||||
|
||||
```shell
|
||||
ollama launch copilot --model kimi-k2.5:cloud
|
||||
```
|
||||
|
||||
## Recommended Models
|
||||
|
||||
- `kimi-k2.5:cloud`
|
||||
- `glm-5:cloud`
|
||||
- `minimax-m2.7:cloud`
|
||||
- `qwen3.5:cloud`
|
||||
- `glm-4.7-flash`
|
||||
- `qwen3.5`
|
||||
|
||||
Cloud models are also available at [ollama.com/search?c=cloud](https://ollama.com/search?c=cloud).
|
||||
|
||||
## Non-interactive (headless) mode
|
||||
|
||||
Run Copilot CLI without interaction for use in Docker, CI/CD, or scripts:
|
||||
|
||||
```shell
|
||||
ollama launch copilot --model kimi-k2.5:cloud --yes -- -p "how does this repository work?"
|
||||
```
|
||||
|
||||
The `--yes` flag auto-pulls the model, skips selectors, and requires `--model` to be specified. Arguments after `--` are passed directly to Copilot CLI.
|
||||
|
||||
## Manual setup
|
||||
|
||||
Copilot CLI connects to Ollama using the OpenAI-compatible API via environment variables.
|
||||
|
||||
1. Set the environment variables:
|
||||
|
||||
```shell
|
||||
export COPILOT_PROVIDER_BASE_URL=http://localhost:11434/v1
|
||||
export COPILOT_PROVIDER_API_KEY=
|
||||
export COPILOT_PROVIDER_WIRE_API=responses
|
||||
export COPILOT_MODEL=qwen3.5
|
||||
```
|
||||
|
||||
1. Run Copilot CLI:
|
||||
|
||||
```shell
|
||||
copilot
|
||||
```
|
||||
|
||||
Or run with environment variables inline:
|
||||
|
||||
```shell
|
||||
COPILOT_PROVIDER_BASE_URL=http://localhost:11434/v1 COPILOT_PROVIDER_API_KEY= COPILOT_PROVIDER_WIRE_API=responses COPILOT_MODEL=glm-5:cloud copilot
|
||||
```
|
||||
|
||||
**Note:** Copilot requires a large context window. We recommend at least 64k tokens. See the [context length documentation](/context-length) for how to adjust context length in Ollama.
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
title: Hermes Agent
|
||||
---
|
||||
|
||||
Hermes Agent is a self-improving AI agent built by Nous Research. It features automatic skill creation, cross-session memory, and connects messaging platforms (Telegram, Discord, Slack, WhatsApp, Signal, Email) to models through a unified gateway.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
ollama launch hermes
|
||||
```
|
||||
|
||||
### Pull a model
|
||||
|
||||
Before running the setup wizard, make sure you have a model available. Hermes will auto-detect models downloaded through Ollama.
|
||||
|
||||
```bash
|
||||
ollama pull kimi-k2.5:cloud
|
||||
```
|
||||
|
||||
See [Recommended models](#recommended-models) for more options.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
### Set up
|
||||
|
||||
After installation, Hermes launches the setup wizard automatically. Choose **Quick setup**:
|
||||
|
||||
```
|
||||
How would you like to set up Hermes?
|
||||
|
||||
→ Quick setup — provider, model & messaging (recommended)
|
||||
Full setup — configure everything
|
||||
```
|
||||
|
||||
### Connect to Ollama
|
||||
|
||||
1. Select **More providers...**
|
||||
2. Select **Custom endpoint (enter URL manually)**
|
||||
3. Set the API base URL to the Ollama OpenAI-compatible endpoint:
|
||||
|
||||
```
|
||||
API base URL [e.g. https://api.example.com/v1]: http://127.0.0.1:11434/v1
|
||||
```
|
||||
|
||||
4. Leave the API key blank (not required for local Ollama):
|
||||
|
||||
```
|
||||
API key [optional]:
|
||||
```
|
||||
|
||||
5. Hermes auto-detects downloaded models, confirm the one you want:
|
||||
|
||||
```
|
||||
Verified endpoint via http://127.0.0.1:11434/v1/models (1 model(s) visible)
|
||||
Detected model: kimi-k2.5:cloud
|
||||
Use this model? [Y/n]:
|
||||
```
|
||||
|
||||
6. Leave context length blank to auto-detect:
|
||||
|
||||
```
|
||||
Context length in tokens [leave blank for auto-detect]:
|
||||
```
|
||||
|
||||
### Connect messaging
|
||||
|
||||
Optionally connect a messaging platform during setup:
|
||||
|
||||
```
|
||||
Connect a messaging platform? (Telegram, Discord, etc.)
|
||||
|
||||
→ Set up messaging now (recommended)
|
||||
Skip — set up later with 'hermes setup gateway'
|
||||
```
|
||||
|
||||
### Launch
|
||||
|
||||
```
|
||||
Launch hermes chat now? [Y/n]: Y
|
||||
```
|
||||
|
||||
## Recommended models
|
||||
|
||||
**Cloud models**:
|
||||
|
||||
- `kimi-k2.5:cloud` — Multimodal reasoning with subagents
|
||||
- `qwen3.5:cloud` — Reasoning, coding, and agentic tool use with vision
|
||||
- `glm-5.1:cloud` — Reasoning and code generation
|
||||
- `minimax-m2.7:cloud` — Fast, efficient coding and real-world productivity
|
||||
|
||||
**Local models:**
|
||||
|
||||
- `gemma4` — Reasoning and code generation locally (~16 GB VRAM)
|
||||
- `qwen3.5` — Reasoning, coding, and visual understanding locally (~11 GB VRAM)
|
||||
|
||||
More models at [ollama.com/search](https://ollama.com/models).
|
||||
|
||||
## Configure later
|
||||
|
||||
Re-run the setup wizard at any time:
|
||||
|
||||
```bash
|
||||
hermes setup
|
||||
```
|
||||
|
||||
To configure just messaging:
|
||||
|
||||
```bash
|
||||
hermes setup gateway
|
||||
```
|
||||
@@ -10,6 +10,7 @@ Coding assistants that can read, modify, and execute code in your projects.
|
||||
|
||||
- [Claude Code](/integrations/claude-code)
|
||||
- [Codex](/integrations/codex)
|
||||
- [Copilot CLI](/integrations/copilot-cli)
|
||||
- [OpenCode](/integrations/opencode)
|
||||
- [Droid](/integrations/droid)
|
||||
- [Goose](/integrations/goose)
|
||||
@@ -20,6 +21,7 @@ Coding assistants that can read, modify, and execute code in your projects.
|
||||
AI assistants that help with everyday tasks.
|
||||
|
||||
- [OpenClaw](/integrations/openclaw)
|
||||
- [Hermes Agent](/integrations/hermes)
|
||||
|
||||
## IDEs & Editors
|
||||
|
||||
|
||||
@@ -59,12 +59,14 @@ If the gateway is already running, it restarts automatically to pick up the new
|
||||
**Cloud models**:
|
||||
|
||||
- `kimi-k2.5:cloud` — Multimodal reasoning with subagents
|
||||
- `qwen3.5:cloud` — Reasoning, coding, and agentic tool use with vision
|
||||
- `glm-5.1:cloud` — Reasoning and code generation
|
||||
- `minimax-m2.7:cloud` — Fast, efficient coding and real-world productivity
|
||||
- `glm-5:cloud` — Reasoning and code generation
|
||||
|
||||
**Local models:**
|
||||
|
||||
- `glm-4.7-flash` — Reasoning and code generation locally (~25 GB VRAM)
|
||||
- `gemma4` — Reasoning and code generation locally (~16 GB VRAM)
|
||||
- `qwen3.5` — Reasoning, coding, and visual understanding locally (~11 GB VRAM)
|
||||
|
||||
More models at [ollama.com/search](https://ollama.com/search?c=cloud).
|
||||
|
||||
|
||||
@@ -28,79 +28,4 @@ To configure without launching:
|
||||
ollama launch opencode --config
|
||||
```
|
||||
|
||||
### Manual setup
|
||||
|
||||
Add a configuration block to `~/.config/opencode/opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"ollama": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "Ollama",
|
||||
"options": {
|
||||
"baseURL": "http://localhost:11434/v1"
|
||||
},
|
||||
"models": {
|
||||
"qwen3-coder": {
|
||||
"name": "qwen3-coder"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Cloud Models
|
||||
|
||||
`glm-4.7:cloud` is the recommended model for use with OpenCode.
|
||||
|
||||
Add the cloud configuration to `~/.config/opencode/opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"ollama": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "Ollama",
|
||||
"options": {
|
||||
"baseURL": "http://localhost:11434/v1"
|
||||
},
|
||||
"models": {
|
||||
"glm-4.7:cloud": {
|
||||
"name": "glm-4.7:cloud"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Connecting to ollama.com
|
||||
|
||||
1. Create an [API key](https://ollama.com/settings/keys) from ollama.com and export it as `OLLAMA_API_KEY`.
|
||||
2. Update `~/.config/opencode/opencode.json` to point to ollama.com:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"ollama": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "Ollama Cloud",
|
||||
"options": {
|
||||
"baseURL": "https://ollama.com/v1"
|
||||
},
|
||||
"models": {
|
||||
"glm-4.7:cloud": {
|
||||
"name": "glm-4.7:cloud"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run `opencode` in a new terminal to load the new settings.
|
||||
<Note>`ollama launch opencode` passes its configuration to OpenCode inline via the `OPENCODE_CONFIG_CONTENT` environment variable. OpenCode deep-merges its config sources on startup, so anything you declare in `~/.config/opencode/opencode.json` is still respected and available inside OpenCode. Models declared only in `opencode.json` won't appear in `ollama launch`'s model-selection menu.</Note>
|
||||
@@ -890,6 +890,7 @@ func (f GGML) FlashAttention() bool {
|
||||
return slices.Contains([]string{
|
||||
"bert",
|
||||
"gemma3",
|
||||
"gemma4",
|
||||
"glm4moelite",
|
||||
"glmocr",
|
||||
"gptoss", "gpt-oss",
|
||||
|
||||
@@ -106,5 +106,5 @@ require (
|
||||
golang.org/x/term v0.36.0
|
||||
golang.org/x/text v0.30.0
|
||||
google.golang.org/protobuf v1.34.1
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
@@ -0,0 +1,107 @@
|
||||
//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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
//go:build integration
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
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
|
||||
// from disk and uses the --experimental CLI path.
|
||||
func skipIfRemote(t *testing.T) {
|
||||
t.Helper()
|
||||
host := os.Getenv("OLLAMA_HOST")
|
||||
if host == "" {
|
||||
return // default is localhost
|
||||
}
|
||||
// Strip scheme if present
|
||||
_, hostport, ok := strings.Cut(host, "://")
|
||||
if !ok {
|
||||
hostport = host
|
||||
}
|
||||
h, _, err := net.SplitHostPort(hostport)
|
||||
if err != nil {
|
||||
h = hostport
|
||||
}
|
||||
if h == "" || h == "localhost" {
|
||||
return
|
||||
}
|
||||
ip := net.ParseIP(h)
|
||||
if ip != nil && (ip.IsLoopback() || ip.IsUnspecified()) {
|
||||
return
|
||||
}
|
||||
t.Skipf("safetensors/imagegen creation requires a local server (OLLAMA_HOST=%s)", host)
|
||||
}
|
||||
|
||||
// findHFCLI returns the path to the HuggingFace CLI, or "" if not found.
|
||||
func findHFCLI() string {
|
||||
for _, name := range []string{"huggingface-cli", "hf"} {
|
||||
if p, err := exec.LookPath(name); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// downloadHFModel idempotently downloads a HuggingFace model to destDir.
|
||||
// Skips the test if CLI is missing and model isn't already present.
|
||||
func downloadHFModel(t *testing.T, repo, destDir string, extraArgs ...string) {
|
||||
t.Helper()
|
||||
|
||||
// Check if model already exists
|
||||
if _, err := os.Stat(destDir); err == nil {
|
||||
entries, err := os.ReadDir(destDir)
|
||||
if err == nil && len(entries) > 0 {
|
||||
t.Logf("Model %s already present at %s", repo, destDir)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
cli := findHFCLI()
|
||||
if cli == "" {
|
||||
t.Skipf("HuggingFace CLI not found and model %s not present at %s", repo, destDir)
|
||||
}
|
||||
|
||||
t.Logf("Downloading %s to %s", repo, destDir)
|
||||
os.MkdirAll(destDir, 0o755)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
args := []string{"download", repo, "--local-dir", destDir}
|
||||
args = append(args, extraArgs...)
|
||||
cmd := exec.CommandContext(ctx, cli, args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatalf("Failed to download %s: %v", repo, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ollamaBin returns the path to the ollama binary to use for tests.
|
||||
// Prefers OLLAMA_BIN env, then falls back to the built binary at ../ollama
|
||||
// (same binary the integration test server uses).
|
||||
func ollamaBin() string {
|
||||
if bin := os.Getenv("OLLAMA_BIN"); bin != "" {
|
||||
return bin
|
||||
}
|
||||
if abs, err := filepath.Abs("../ollama"); err == nil {
|
||||
if _, err := os.Stat(abs); err == nil {
|
||||
return abs
|
||||
}
|
||||
}
|
||||
return "ollama"
|
||||
}
|
||||
|
||||
// ensureMLXLibraryPath sets OLLAMA_LIBRARY_PATH so the MLX dynamic library
|
||||
// is discoverable. Integration tests run from integration/ dir, so the
|
||||
// default CWD-based search won't find the library at the repo root.
|
||||
func ensureMLXLibraryPath(t *testing.T) {
|
||||
t.Helper()
|
||||
if libPath, err := filepath.Abs("../build/lib/ollama"); err == nil {
|
||||
if _, err := os.Stat(libPath); err == nil {
|
||||
if existing := os.Getenv("OLLAMA_LIBRARY_PATH"); existing != "" {
|
||||
t.Setenv("OLLAMA_LIBRARY_PATH", existing+string(filepath.ListSeparator)+libPath)
|
||||
} else {
|
||||
t.Setenv("OLLAMA_LIBRARY_PATH", libPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runOllamaCreate runs "ollama create" as a subprocess. Skips the test if
|
||||
// the error indicates the server is remote.
|
||||
func runOllamaCreate(ctx context.Context, t *testing.T, args ...string) {
|
||||
t.Helper()
|
||||
createCmd := exec.CommandContext(ctx, ollamaBin(), append([]string{"create"}, args...)...)
|
||||
var createStderr strings.Builder
|
||||
createCmd.Stdout = os.Stdout
|
||||
createCmd.Stderr = io.MultiWriter(os.Stderr, &createStderr)
|
||||
if err := createCmd.Run(); err != nil {
|
||||
if strings.Contains(createStderr.String(), "remote") {
|
||||
t.Skip("safetensors creation requires a local server")
|
||||
}
|
||||
t.Fatalf("ollama create failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateSafetensorsLLM(t *testing.T) {
|
||||
skipIfRemote(t)
|
||||
|
||||
modelDir := filepath.Join(testdataModelsDir, "TinyLlama-1.1B")
|
||||
downloadHFModel(t, "TinyLlama/TinyLlama-1.1B-Chat-v1.0", modelDir)
|
||||
|
||||
ensureMLXLibraryPath(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
client, _, cleanup := InitServerConnection(ctx, t)
|
||||
defer cleanup()
|
||||
|
||||
modelName := "test-tinyllama-safetensors"
|
||||
|
||||
absModelDir, err := filepath.Abs(modelDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get absolute path: %v", err)
|
||||
}
|
||||
|
||||
// Create a Modelfile pointing to the model directory.
|
||||
// Include a chat template since the safetensors importer doesn't extract
|
||||
// chat_template from tokenizer_config.json yet.
|
||||
modelfileContent := "FROM " + absModelDir + "\n" +
|
||||
"TEMPLATE \"{{ if .System }}<|system|>\n{{ .System }}</s>\n{{ end }}" +
|
||||
"{{ if .Prompt }}<|user|>\n{{ .Prompt }}</s>\n{{ end }}" +
|
||||
"<|assistant|>\n{{ .Response }}</s>\n\"\n"
|
||||
tmpModelfile := filepath.Join(t.TempDir(), "Modelfile")
|
||||
if err := os.WriteFile(tmpModelfile, []byte(modelfileContent), 0o644); err != nil {
|
||||
t.Fatalf("Failed to write Modelfile: %v", err)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// Use the chat API for proper template application.
|
||||
chatReq := &api.ChatRequest{
|
||||
Model: modelName,
|
||||
Messages: []api.Message{
|
||||
{Role: "user", Content: "Write a short sentence about the weather."},
|
||||
},
|
||||
Options: map[string]interface{}{
|
||||
"num_predict": 20,
|
||||
"temperature": 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
var output strings.Builder
|
||||
err = client.Chat(ctx, chatReq, func(resp api.ChatResponse) error {
|
||||
output.WriteString(resp.Message.Content)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Chat failed: %v", err)
|
||||
}
|
||||
|
||||
text := output.String()
|
||||
t.Logf("Generated output: %q", text)
|
||||
assertCoherentOutput(t, text)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateGGUF(t *testing.T) {
|
||||
modelDir := filepath.Join(testdataModelsDir, "Llama-3.2-1B-GGUF")
|
||||
downloadHFModel(t, "bartowski/Llama-3.2-1B-Instruct-GGUF", modelDir,
|
||||
"--include", "Llama-3.2-1B-Instruct-IQ3_M.gguf")
|
||||
|
||||
// Find the GGUF file
|
||||
entries, err := os.ReadDir(modelDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read model dir: %v", err)
|
||||
}
|
||||
|
||||
var ggufPath string
|
||||
for _, e := range entries {
|
||||
if filepath.Ext(e.Name()) == ".gguf" {
|
||||
ggufPath = filepath.Join(modelDir, e.Name())
|
||||
break
|
||||
}
|
||||
}
|
||||
if ggufPath == "" {
|
||||
t.Skip("No GGUF file found in model directory")
|
||||
}
|
||||
|
||||
absGGUF, err := filepath.Abs(ggufPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get absolute path: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
client, _, cleanup := InitServerConnection(ctx, t)
|
||||
defer cleanup()
|
||||
|
||||
modelName := "test-llama32-gguf"
|
||||
|
||||
// Create a Modelfile and use the CLI
|
||||
tmpModelfile := filepath.Join(t.TempDir(), "Modelfile")
|
||||
if err := os.WriteFile(tmpModelfile, []byte("FROM "+absGGUF+"\n"), 0o644); err != nil {
|
||||
t.Fatalf("Failed to write Modelfile: %v", err)
|
||||
}
|
||||
|
||||
createCmd := exec.CommandContext(ctx, ollamaBin(), "create", modelName, "-f", tmpModelfile)
|
||||
createCmd.Stdout = os.Stdout
|
||||
createCmd.Stderr = os.Stderr
|
||||
if err := createCmd.Run(); err != nil {
|
||||
t.Fatalf("ollama create failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify model exists
|
||||
showReq := &api.ShowRequest{Name: modelName}
|
||||
_, err = client.Show(ctx, showReq)
|
||||
if err != nil {
|
||||
t.Fatalf("Model show failed after create: %v", err)
|
||||
}
|
||||
|
||||
// Generate and verify output is coherent
|
||||
genReq := &api.GenerateRequest{
|
||||
Model: modelName,
|
||||
Prompt: "Write a short sentence about the weather.",
|
||||
Options: map[string]interface{}{
|
||||
"num_predict": 20,
|
||||
"temperature": 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
var output strings.Builder
|
||||
err = client.Generate(ctx, genReq, func(resp api.GenerateResponse) error {
|
||||
output.WriteString(resp.Response)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
|
||||
text := output.String()
|
||||
t.Logf("Generated output: %q", text)
|
||||
assertCoherentOutput(t, text)
|
||||
|
||||
// Cleanup
|
||||
deleteReq := &api.DeleteRequest{Model: modelName}
|
||||
if err := client.Delete(ctx, deleteReq); err != nil {
|
||||
t.Logf("Warning: failed to delete test model: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// assertCoherentOutput checks that model output looks like real language, not
|
||||
// garbled binary or repeated garbage. This catches corrupted model creation
|
||||
// where inference "works" but produces nonsense.
|
||||
func assertCoherentOutput(t *testing.T, text string) {
|
||||
t.Helper()
|
||||
|
||||
if len(text) == 0 {
|
||||
t.Fatal("model produced empty output")
|
||||
}
|
||||
|
||||
// Check minimum length — 20 tokens should produce at least a few words
|
||||
if len(text) < 5 {
|
||||
t.Fatalf("model output suspiciously short (%d bytes): %q", len(text), text)
|
||||
}
|
||||
|
||||
// Check for mostly-printable ASCII/Unicode — garbled models often emit
|
||||
// high ratios of control characters or replacement characters
|
||||
unprintable := 0
|
||||
for _, r := range text {
|
||||
if r < 0x20 && r != '\n' && r != '\r' && r != '\t' {
|
||||
unprintable++
|
||||
}
|
||||
if r == '\ufffd' { // Unicode replacement character
|
||||
unprintable++
|
||||
}
|
||||
}
|
||||
ratio := float64(unprintable) / float64(len([]rune(text)))
|
||||
if ratio > 0.3 {
|
||||
t.Fatalf("model output is %.0f%% unprintable characters (likely garbled): %q", ratio*100, text)
|
||||
}
|
||||
|
||||
// Check it contains at least one space — real language has word boundaries
|
||||
if !strings.Contains(text, " ") {
|
||||
t.Fatalf("model output contains no spaces (likely garbled): %q", text)
|
||||
}
|
||||
|
||||
// Check for excessive repetition — a broken model might repeat one token
|
||||
words := strings.Fields(text)
|
||||
if len(words) >= 4 {
|
||||
counts := map[string]int{}
|
||||
for _, w := range words {
|
||||
counts[strings.ToLower(w)]++
|
||||
}
|
||||
for w, c := range counts {
|
||||
if c > len(words)*3/4 {
|
||||
t.Fatalf("model output is excessively repetitive (%q appears %d/%d times): %q", w, c, len(words), text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package common
|
||||
|
||||
// #cgo CXXFLAGS: -std=c++17
|
||||
// #cgo CXXFLAGS: -std=c++17 -Wno-deprecated-declarations
|
||||
// #cgo CPPFLAGS: -I${SRCDIR}/../include -I${SRCDIR}/../vendor
|
||||
// #cgo CPPFLAGS: -I${SRCDIR}/../../../ml/backend/ggml/ggml/include
|
||||
import "C"
|
||||
@@ -8,12 +8,13 @@ it does not allocate memory for tensors or operations. This can
|
||||
be used for calculating memory requirements. Tensors and graphs
|
||||
must be recreated with no-alloc set to false before loading data.
|
||||
---
|
||||
ggml/include/ggml-backend.h | 1 +
|
||||
ggml/src/ggml-backend-impl.h | 16 +++
|
||||
ggml/src/ggml-backend.cpp | 75 ++++++++++-
|
||||
ggml/src/ggml-cuda/common.cuh | 62 ++++++++-
|
||||
ggml/src/ggml-cuda/ggml-cuda.cu | 224 ++++++++++++++++++++++++++------
|
||||
5 files changed, 333 insertions(+), 45 deletions(-)
|
||||
ggml/include/ggml-backend.h | 1 +
|
||||
ggml/src/ggml-backend-impl.h | 16 +++
|
||||
ggml/src/ggml-backend.cpp | 75 ++++++++++-
|
||||
ggml/src/ggml-cuda/common.cuh | 85 +++++++++++-
|
||||
ggml/src/ggml-cuda/ggml-cuda.cu | 224 +++++++++++++++++++++++++------
|
||||
ggml/src/ggml-cuda/vendors/hip.h | 1 +
|
||||
6 files changed, 357 insertions(+), 45 deletions(-)
|
||||
|
||||
diff --git a/ggml/include/ggml-backend.h b/ggml/include/ggml-backend.h
|
||||
index 93c95602d..dbbb61d9c 100644
|
||||
@@ -226,10 +227,10 @@ index 498186a7c..7746e8b92 100644
|
||||
|
||||
void ggml_backend_sched_set_tensor_backend(ggml_backend_sched_t sched, struct ggml_tensor * node, ggml_backend_t backend) {
|
||||
diff --git a/ggml/src/ggml-cuda/common.cuh b/ggml/src/ggml-cuda/common.cuh
|
||||
index 9fcb2f9fd..e800ee8f6 100644
|
||||
index 9fcb2f9fd..79673ac2e 100644
|
||||
--- a/ggml/src/ggml-cuda/common.cuh
|
||||
+++ b/ggml/src/ggml-cuda/common.cuh
|
||||
@@ -37,6 +37,41 @@
|
||||
@@ -37,6 +37,64 @@
|
||||
#include "vendors/cuda.h"
|
||||
#endif // defined(GGML_USE_HIP)
|
||||
|
||||
@@ -261,17 +262,40 @@ index 9fcb2f9fd..e800ee8f6 100644
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+static cublasStatus_t cublasGemmBatchedExReserve(
|
||||
+ cublasHandle_t handle, cublasOperation_t transa, cublasOperation_t transb,
|
||||
+ int m, int n, int k,
|
||||
+ const void *alpha,
|
||||
+ const void *const Aarray[], cudaDataType_t Atype, int lda,
|
||||
+ const void *const Barray[], cudaDataType_t Btype, int ldb,
|
||||
+ const void *beta,
|
||||
+ void *const Carray[], cudaDataType_t Ctype, int ldc,
|
||||
+ int batchCount,
|
||||
+ cublasComputeType_t computeType, cublasGemmAlgo_t algo) {
|
||||
+ if (!reserving_graph) {
|
||||
+ return cublasGemmBatchedEx(handle, transa, transb, m, n, k,
|
||||
+ alpha, const_cast<const void **>(Aarray), Atype, lda,
|
||||
+ const_cast<const void **>(Barray), Btype, ldb,
|
||||
+ beta, const_cast<void **>(Carray), Ctype, ldc,
|
||||
+ batchCount, computeType, algo);
|
||||
+ } else {
|
||||
+ return CUBLAS_STATUS_SUCCESS;
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+#undef cudaMemcpyAsync
|
||||
+#define cudaMemcpyAsync cudaMemcpyAsyncReserve
|
||||
+#undef cudaMemcpy2DAsync
|
||||
+#define cudaMemcpy2DAsync cudaMemcpy2DAsyncReserve
|
||||
+#undef cudaMemsetAsync
|
||||
+#define cudaMemsetAsync cudaMemsetAsyncReserve
|
||||
+#undef cublasGemmBatchedEx
|
||||
+#define cublasGemmBatchedEx cublasGemmBatchedExReserve
|
||||
+
|
||||
#define STRINGIZE_IMPL(...) #__VA_ARGS__
|
||||
#define STRINGIZE(...) STRINGIZE_IMPL(__VA_ARGS__)
|
||||
|
||||
@@ -941,6 +976,9 @@ struct ggml_cuda_pool {
|
||||
@@ -941,6 +999,9 @@ struct ggml_cuda_pool {
|
||||
|
||||
virtual void * alloc(size_t size, size_t * actual_size) = 0;
|
||||
virtual void free(void * ptr, size_t size) = 0;
|
||||
@@ -281,7 +305,7 @@ index 9fcb2f9fd..e800ee8f6 100644
|
||||
};
|
||||
|
||||
template<typename T>
|
||||
@@ -1232,11 +1270,15 @@ struct ggml_backend_cuda_context {
|
||||
@@ -1232,11 +1293,15 @@ struct ggml_backend_cuda_context {
|
||||
// pool
|
||||
std::unique_ptr<ggml_cuda_pool> pools[GGML_CUDA_MAX_DEVICES][GGML_CUDA_MAX_STREAMS];
|
||||
|
||||
@@ -299,7 +323,7 @@ index 9fcb2f9fd..e800ee8f6 100644
|
||||
}
|
||||
return *pools[device][curr_stream_no];
|
||||
}
|
||||
@@ -1244,6 +1286,22 @@ struct ggml_backend_cuda_context {
|
||||
@@ -1244,6 +1309,22 @@ struct ggml_backend_cuda_context {
|
||||
ggml_cuda_pool & pool() {
|
||||
return pool(device);
|
||||
}
|
||||
@@ -682,3 +706,15 @@ index 25548629d..eeaae3fe4 100644
|
||||
};
|
||||
|
||||
static ggml_guid_t ggml_backend_cuda_guid() {
|
||||
diff --git a/ggml/src/ggml-cuda/vendors/hip.h b/ggml/src/ggml-cuda/vendors/hip.h
|
||||
index 951a88d56..b9627eb8b 100644
|
||||
--- a/ggml/src/ggml-cuda/vendors/hip.h
|
||||
+++ b/ggml/src/ggml-cuda/vendors/hip.h
|
||||
@@ -37,6 +37,7 @@
|
||||
#define cublasCreate hipblasCreate
|
||||
#define cublasDestroy hipblasDestroy
|
||||
#define cublasGemmEx hipblasGemmEx
|
||||
+#define cublasGemmAlgo_t hipblasGemmAlgo_t
|
||||
#define cublasGemmBatchedEx hipblasGemmBatchedEx
|
||||
#define cublasGemmStridedBatchedEx hipblasGemmStridedBatchedEx
|
||||
#define cublasHandle_t hipblasHandle_t
|
||||
@@ -110,10 +110,10 @@ index eeaae3fe4..6852d2e20 100644
|
||||
|
||||
// backend reg
|
||||
diff --git a/ggml/src/ggml-cuda/vendors/hip.h b/ggml/src/ggml-cuda/vendors/hip.h
|
||||
index 951a88d56..4e162258d 100644
|
||||
index b9627eb8b..e3310bdf3 100644
|
||||
--- a/ggml/src/ggml-cuda/vendors/hip.h
|
||||
+++ b/ggml/src/ggml-cuda/vendors/hip.h
|
||||
@@ -49,6 +49,7 @@
|
||||
@@ -50,6 +50,7 @@
|
||||
#define cudaDeviceDisablePeerAccess hipDeviceDisablePeerAccess
|
||||
#define cudaDeviceEnablePeerAccess hipDeviceEnablePeerAccess
|
||||
#define cudaDeviceProp hipDeviceProp_t
|
||||
|
||||
@@ -183,7 +183,7 @@ index 6852d2e20..334a30135 100644
|
||||
/* .iface = */ ggml_backend_cuda_device_interface,
|
||||
/* .reg = */ ®,
|
||||
diff --git a/ggml/src/ggml-cuda/vendors/hip.h b/ggml/src/ggml-cuda/vendors/hip.h
|
||||
index 4e162258d..d89e35a8e 100644
|
||||
index e3310bdf3..fe4b5349f 100644
|
||||
--- a/ggml/src/ggml-cuda/vendors/hip.h
|
||||
+++ b/ggml/src/ggml-cuda/vendors/hip.h
|
||||
@@ -5,6 +5,8 @@
|
||||
@@ -195,7 +195,7 @@ index 4e162258d..d89e35a8e 100644
|
||||
|
||||
#if defined(GGML_HIP_ROCWMMA_FATTN)
|
||||
#include <rocwmma/rocwmma-version.hpp>
|
||||
@@ -51,6 +53,7 @@
|
||||
@@ -52,6 +54,7 @@
|
||||
#define cudaDeviceProp hipDeviceProp_t
|
||||
#define cudaDeviceReset hipDeviceReset
|
||||
#define cudaDeviceSynchronize hipDeviceSynchronize
|
||||
|
||||
@@ -6,11 +6,11 @@ Subject: [PATCH] interleave multi rope
|
||||
since ollama doesn't use mrope for anything else, change it to mean the
|
||||
interleaved version used for qwen3vl
|
||||
---
|
||||
ggml/src/ggml-cpu/ops.cpp | 8 ++++----
|
||||
ggml/src/ggml-cuda/rope.cu | 8 ++++----
|
||||
ggml/src/ggml-metal/ggml-metal.metal | 8 ++++----
|
||||
ggml/src/ggml-vulkan/vulkan-shaders/rope_funcs.glsl | 8 ++++----
|
||||
4 files changed, 16 insertions(+), 16 deletions(-)
|
||||
ggml/src/ggml-cpu/ops.cpp | 8 ++++----
|
||||
ggml/src/ggml-cuda/rope.cu | 8 ++++----
|
||||
ggml/src/ggml-metal/ggml-metal.metal | 10 +++++-----
|
||||
ggml/src/ggml-vulkan/vulkan-shaders/rope_funcs.glsl | 8 ++++----
|
||||
4 files changed, 17 insertions(+), 17 deletions(-)
|
||||
|
||||
diff --git a/ggml/src/ggml-cpu/ops.cpp b/ggml/src/ggml-cpu/ops.cpp
|
||||
index 7d1733adb..f4aae5332 100644
|
||||
@@ -59,12 +59,15 @@ index 88ed79111..71ca60214 100644
|
||||
} else {
|
||||
if (sector < sections.v[0]) {
|
||||
diff --git a/ggml/src/ggml-metal/ggml-metal.metal b/ggml/src/ggml-metal/ggml-metal.metal
|
||||
index 236838e9e..c98d269d1 100644
|
||||
index 236838e9e..18b8bb1b1 100644
|
||||
--- a/ggml/src/ggml-metal/ggml-metal.metal
|
||||
+++ b/ggml/src/ggml-metal/ggml-metal.metal
|
||||
@@ -4242,14 +4242,14 @@ kernel void kernel_rope_multi(
|
||||
@@ -4240,16 +4240,16 @@ kernel void kernel_rope_multi(
|
||||
const int sec_w012 = args.sect_0 + args.sect_1 + args.sect_2; // end of section 2
|
||||
const int sector = ic % sect_dims;
|
||||
|
||||
float theta_base;
|
||||
- float theta_base;
|
||||
+ float theta_base = 0.0;
|
||||
if (FC_rope_is_imrope) {
|
||||
- if (sector % 3 == 1 && sector < 3 * args.sect_1) { // h
|
||||
+ if (sector % 3 == 1 && sector < 1 + 3 * args.sect_1) { // h
|
||||
|
||||
@@ -296,7 +296,7 @@ index e99c1763f..80864f303 100644
|
||||
const size_t smem = FATTN_SMEM(nsg);
|
||||
|
||||
diff --git a/ggml/src/ggml-metal/ggml-metal.metal b/ggml/src/ggml-metal/ggml-metal.metal
|
||||
index c98d269d1..d33c16079 100644
|
||||
index 18b8bb1b1..114767785 100644
|
||||
--- a/ggml/src/ggml-metal/ggml-metal.metal
|
||||
+++ b/ggml/src/ggml-metal/ggml-metal.metal
|
||||
@@ -6166,6 +6166,7 @@ kernel void kernel_flash_attn_ext(
|
||||
|
||||
@@ -204,7 +204,7 @@ index 902b54452..a475183d3 100644
|
||||
int ggml_metal_op_norm (ggml_metal_op_t ctx, int idx);
|
||||
int ggml_metal_op_rope (ggml_metal_op_t ctx, int idx);
|
||||
diff --git a/ggml/src/ggml-metal/ggml-metal.metal b/ggml/src/ggml-metal/ggml-metal.metal
|
||||
index d33c16079..c37447a10 100644
|
||||
index 114767785..876a9eecc 100644
|
||||
--- a/ggml/src/ggml-metal/ggml-metal.metal
|
||||
+++ b/ggml/src/ggml-metal/ggml-metal.metal
|
||||
@@ -3012,6 +3012,66 @@ kernel void kernel_l2_norm_f32(
|
||||
|
||||
@@ -24,7 +24,7 @@ index 4ac135603..ac5ad53db 100644
|
||||
// ref: https://developer.apple.com/metal/Metal-Shading-Language-Specification.pdf (Table 2.5)
|
||||
//switch (op->src[0]->type) {
|
||||
diff --git a/ggml/src/ggml-metal/ggml-metal.metal b/ggml/src/ggml-metal/ggml-metal.metal
|
||||
index c37447a10..4f338aa13 100644
|
||||
index 876a9eecc..b14a0000c 100644
|
||||
--- a/ggml/src/ggml-metal/ggml-metal.metal
|
||||
+++ b/ggml/src/ggml-metal/ggml-metal.metal
|
||||
@@ -9427,6 +9427,7 @@ template [[host_name("kernel_mul_mm_id_map0_ne20_6" )]] kernel kernel_mul_mm_id_
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||
From: Daniel Hiltgen <daniel@ollama.com>
|
||||
Date: Mon, 6 Apr 2026 19:56:36 -0700
|
||||
Subject: [PATCH] backport kernels for gemma4
|
||||
|
||||
---
|
||||
ggml/src/ggml-cuda/fattn-mma-f16.cuh | 26 ++++++++++++-
|
||||
ggml/src/ggml-cuda/fattn-tile.cu | 4 ++
|
||||
ggml/src/ggml-cuda/fattn-tile.cuh | 37 +++++++++++++++----
|
||||
ggml/src/ggml-cuda/fattn.cu | 11 +++++-
|
||||
...attn-mma-f16-instance-ncols1_1-ncols2_8.cu | 1 +
|
||||
...ttn-mma-f16-instance-ncols1_16-ncols2_4.cu | 1 +
|
||||
...attn-mma-f16-instance-ncols1_2-ncols2_4.cu | 1 +
|
||||
...attn-mma-f16-instance-ncols1_2-ncols2_8.cu | 1 +
|
||||
...attn-mma-f16-instance-ncols1_4-ncols2_4.cu | 1 +
|
||||
...attn-mma-f16-instance-ncols1_4-ncols2_8.cu | 1 +
|
||||
...attn-mma-f16-instance-ncols1_8-ncols2_4.cu | 1 +
|
||||
...attn-mma-f16-instance-ncols1_8-ncols2_8.cu | 1 +
|
||||
.../fattn-tile-instance-dkq512-dv512.cu | 5 +++
|
||||
ggml/src/ggml-metal/ggml-metal-device.m | 1 +
|
||||
ggml/src/ggml-metal/ggml-metal.metal | 19 ++++++++++
|
||||
15 files changed, 101 insertions(+), 10 deletions(-)
|
||||
create mode 100644 ggml/src/ggml-cuda/template-instances/fattn-tile-instance-dkq512-dv512.cu
|
||||
|
||||
diff --git a/ggml/src/ggml-cuda/fattn-mma-f16.cuh b/ggml/src/ggml-cuda/fattn-mma-f16.cuh
|
||||
index 3dea2205e..b4282d3cb 100644
|
||||
--- a/ggml/src/ggml-cuda/fattn-mma-f16.cuh
|
||||
+++ b/ggml/src/ggml-cuda/fattn-mma-f16.cuh
|
||||
@@ -66,6 +66,11 @@ static constexpr __host__ __device__ fattn_mma_config ggml_cuda_fattn_mma_get_co
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(256, 256, 32, 128, 2, 32, 128, 128, 128, 2, true);
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(256, 256, 64, 128, 2, 32, 128, 128, 128, 2, true);
|
||||
|
||||
+ GGML_CUDA_FATTN_MMA_CONFIG_CASE(512, 512, 8, 64, 4, 32, 256, 256, 128, 1, false);
|
||||
+ GGML_CUDA_FATTN_MMA_CONFIG_CASE(512, 512, 16, 64, 4, 32, 256, 256, 128, 1, false);
|
||||
+ GGML_CUDA_FATTN_MMA_CONFIG_CASE(512, 512, 32, 128, 2, 32, 128, 128, 128, 1, false);
|
||||
+ GGML_CUDA_FATTN_MMA_CONFIG_CASE(512, 512, 64, 256, 1, 32, 128, 128, 128, 1, false);
|
||||
+
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(576, 512, 4, 64, 4, 32, 288, 256, 128, 1, false);
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(576, 512, 8, 64, 4, 32, 288, 256, 128, 1, true);
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(576, 512, 16, 64, 4, 32, 288, 256, 128, 1, false);
|
||||
@@ -81,6 +86,11 @@ static constexpr __host__ __device__ fattn_mma_config ggml_cuda_fattn_mma_get_co
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(256, 256, 32, 128, 2, 64, 128, 128, 64, 2, true);
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(256, 256, 64, 128, 2, 64, 128, 128, 64, 2, true);
|
||||
|
||||
+ GGML_CUDA_FATTN_MMA_CONFIG_CASE(512, 512, 8, 64, 4, 32, 96, 64, 128, 1, false);
|
||||
+ GGML_CUDA_FATTN_MMA_CONFIG_CASE(512, 512, 16, 64, 4, 32, 96, 64, 128, 1, false);
|
||||
+ GGML_CUDA_FATTN_MMA_CONFIG_CASE(512, 512, 32, 128, 2, 32, 128, 128, 128, 1, false);
|
||||
+ GGML_CUDA_FATTN_MMA_CONFIG_CASE(512, 512, 64, 256, 1, 32, 128, 128, 128, 1, false);
|
||||
+
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(576, 512, 4, 64, 4, 32, 96, 64, 128, 1, false);
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(576, 512, 8, 64, 4, 32, 96, 64, 128, 1, true);
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(576, 512, 16, 64, 4, 32, 96, 64, 128, 1, false);
|
||||
@@ -91,6 +101,11 @@ static constexpr __host__ __device__ fattn_mma_config ggml_cuda_fattn_mma_get_co
|
||||
}
|
||||
|
||||
static constexpr __host__ __device__ fattn_mma_config ggml_cuda_fattn_mma_get_config_volta(const int DKQ, const int DV, const int ncols) {
|
||||
+ GGML_CUDA_FATTN_MMA_CONFIG_CASE(512, 512, 8, 64, 4, 32, 256, 256, 64, 1, false);
|
||||
+ GGML_CUDA_FATTN_MMA_CONFIG_CASE(512, 512, 16, 64, 4, 32, 256, 256, 64, 1, false);
|
||||
+ GGML_CUDA_FATTN_MMA_CONFIG_CASE(512, 512, 32, 128, 2, 32, 128, 128, 64, 1, false);
|
||||
+ GGML_CUDA_FATTN_MMA_CONFIG_CASE(512, 512, 64, 256, 1, 32, 128, 128, 64, 1, false);
|
||||
+
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(576, 512, 4, 64, 4, 32, 288, 256, 64, 1, false);
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(576, 512, 8, 64, 4, 32, 288, 256, 64, 1, true);
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(576, 512, 16, 64, 4, 32, 288, 256, 64, 1, false);
|
||||
@@ -1361,7 +1376,7 @@ static __global__ void flash_attn_ext_f16(
|
||||
#if defined(FLASH_ATTN_AVAILABLE) && (defined(VOLTA_MMA_AVAILABLE) || defined(TURING_MMA_AVAILABLE))
|
||||
|
||||
// Skip unused kernel variants for faster compilation:
|
||||
- if (use_logit_softcap && !(DKQ == 128 || DKQ == 256)) {
|
||||
+ if (use_logit_softcap && !(DKQ == 128 || DKQ == 256 || DKQ == 512)) {
|
||||
NO_DEVICE_CODE;
|
||||
return;
|
||||
}
|
||||
@@ -1600,6 +1615,15 @@ DECL_FATTN_MMA_F16_CASE_ALL_NCOLS2(112, 112, 64)
|
||||
DECL_FATTN_MMA_F16_CASE_ALL_NCOLS2(128, 128, 64)
|
||||
DECL_FATTN_MMA_F16_CASE_ALL_NCOLS2(256, 256, 64)
|
||||
|
||||
+extern DECL_FATTN_MMA_F16_CASE(512, 512, 2, 4);
|
||||
+extern DECL_FATTN_MMA_F16_CASE(512, 512, 4, 4);
|
||||
+extern DECL_FATTN_MMA_F16_CASE(512, 512, 8, 4);
|
||||
+extern DECL_FATTN_MMA_F16_CASE(512, 512, 16, 4);
|
||||
+extern DECL_FATTN_MMA_F16_CASE(512, 512, 1, 8);
|
||||
+extern DECL_FATTN_MMA_F16_CASE(512, 512, 2, 8);
|
||||
+extern DECL_FATTN_MMA_F16_CASE(512, 512, 4, 8);
|
||||
+extern DECL_FATTN_MMA_F16_CASE(512, 512, 8, 8);
|
||||
+
|
||||
// The number of viable configurations for Deepseek is very limited:
|
||||
extern DECL_FATTN_MMA_F16_CASE(576, 512, 1, 16);
|
||||
extern DECL_FATTN_MMA_F16_CASE(576, 512, 2, 16);
|
||||
diff --git a/ggml/src/ggml-cuda/fattn-tile.cu b/ggml/src/ggml-cuda/fattn-tile.cu
|
||||
index 3fcb09b7a..25b16e83c 100644
|
||||
--- a/ggml/src/ggml-cuda/fattn-tile.cu
|
||||
+++ b/ggml/src/ggml-cuda/fattn-tile.cu
|
||||
@@ -38,6 +38,10 @@ void ggml_cuda_flash_attn_ext_tile(ggml_backend_cuda_context & ctx, ggml_tensor
|
||||
GGML_ASSERT(V->ne[0] == K->ne[0]);
|
||||
ggml_cuda_flash_attn_ext_tile_case<256, 256>(ctx, dst);
|
||||
} break;
|
||||
+ case 512: {
|
||||
+ GGML_ASSERT(V->ne[0] == K->ne[0]);
|
||||
+ ggml_cuda_flash_attn_ext_tile_case<512, 512>(ctx, dst);
|
||||
+ } break;
|
||||
case 576: {
|
||||
GGML_ASSERT(V->ne[0] == 512);
|
||||
ggml_cuda_flash_attn_ext_tile_case<576, 512>(ctx, dst);
|
||||
diff --git a/ggml/src/ggml-cuda/fattn-tile.cuh b/ggml/src/ggml-cuda/fattn-tile.cuh
|
||||
index 371be7442..43906bd73 100644
|
||||
--- a/ggml/src/ggml-cuda/fattn-tile.cuh
|
||||
+++ b/ggml/src/ggml-cuda/fattn-tile.cuh
|
||||
@@ -68,6 +68,10 @@ static constexpr __host__ __device__ uint32_t ggml_cuda_fattn_tile_get_config_nv
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(256, 256, 16, 256, 2, 64, 64)
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(256, 256, 32, 256, 2, 64, 64)
|
||||
|
||||
+ GGML_CUDA_FATTN_TILE_CONFIG_CASE(512, 512, 4, 128, 2, 64, 64)
|
||||
+ GGML_CUDA_FATTN_TILE_CONFIG_CASE(512, 512, 8, 256, 2, 64, 64)
|
||||
+ GGML_CUDA_FATTN_TILE_CONFIG_CASE(512, 512, 16, 256, 2, 64, 64)
|
||||
+
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(576, 512, 4, 128, 2, 64, 64)
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(576, 512, 8, 256, 2, 64, 64)
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(576, 512, 16, 256, 2, 64, 64)
|
||||
@@ -124,6 +128,10 @@ static constexpr __host__ __device__ uint32_t ggml_cuda_fattn_tile_get_config_nv
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(256, 256, 16, 256, 2, 32, 128)
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(256, 256, 32, 256, 2, 32, 64)
|
||||
|
||||
+ GGML_CUDA_FATTN_TILE_CONFIG_CASE(512, 512, 4, 128, 2, 32, 64)
|
||||
+ GGML_CUDA_FATTN_TILE_CONFIG_CASE(512, 512, 8, 256, 2, 32, 64)
|
||||
+ GGML_CUDA_FATTN_TILE_CONFIG_CASE(512, 512, 16, 256, 2, 32, 64)
|
||||
+
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(576, 512, 4, 128, 2, 32, 64)
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(576, 512, 8, 256, 2, 32, 64)
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(576, 512, 16, 256, 2, 32, 64)
|
||||
@@ -187,6 +195,11 @@ static constexpr __host__ __device__ uint32_t ggml_cuda_fattn_tile_get_config_am
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(256, 256, 16, 256, 2, 32, 128)
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(256, 256, 32, 256, 2, 32, 128)
|
||||
|
||||
+ GGML_CUDA_FATTN_TILE_CONFIG_CASE(512, 512, 4, 128, 2, 64, 64)
|
||||
+ GGML_CUDA_FATTN_TILE_CONFIG_CASE(512, 512, 8, 256, 2, 64, 64)
|
||||
+ GGML_CUDA_FATTN_TILE_CONFIG_CASE(512, 512, 16, 256, 2, 64, 64)
|
||||
+ GGML_CUDA_FATTN_TILE_CONFIG_CASE(512, 512, 32, 512, 1, 128, 64)
|
||||
+
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(576, 512, 4, 128, 2, 64, 64)
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(576, 512, 8, 256, 2, 64, 64)
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(576, 512, 16, 256, 2, 64, 64)
|
||||
@@ -251,6 +264,11 @@ static constexpr __host__ __device__ uint32_t ggml_cuda_fattn_tile_get_config_am
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(256, 256, 16, 256, 5, 32, 256)
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(256, 256, 32, 256, 3, 64, 128)
|
||||
|
||||
+ GGML_CUDA_FATTN_TILE_CONFIG_CASE(512, 512, 4, 128, 2, 64, 64)
|
||||
+ GGML_CUDA_FATTN_TILE_CONFIG_CASE(512, 512, 8, 256, 2, 64, 64)
|
||||
+ GGML_CUDA_FATTN_TILE_CONFIG_CASE(512, 512, 16, 256, 4, 64, 64)
|
||||
+ GGML_CUDA_FATTN_TILE_CONFIG_CASE(512, 512, 32, 256, 2, 128, 64)
|
||||
+
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(576, 512, 4, 128, 2, 64, 64)
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(576, 512, 8, 256, 2, 64, 64)
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(576, 512, 16, 256, 4, 64, 64)
|
||||
@@ -767,7 +785,7 @@ static __global__ void flash_attn_tile(
|
||||
#ifdef GGML_USE_WMMA_FATTN
|
||||
(ncols2 != 1 && DV != 40 && DV != 72 && DV != 512) ||
|
||||
#endif // GGML_USE_WMMA_FATTN
|
||||
- (use_logit_softcap && !(DV == 128 || DV == 256))
|
||||
+ (use_logit_softcap && !(DV == 128 || DV == 256 || DV == 512))
|
||||
) {
|
||||
GGML_UNUSED_VARS(Q, K, V, mask, sinks, KV_max, dst, dst_meta, scale,
|
||||
max_bias, m0, m1, n_head_log2, logit_softcap,
|
||||
@@ -1190,7 +1208,7 @@ static void launch_fattn_tile_switch_ncols2(ggml_backend_cuda_context & ctx, ggm
|
||||
const int gqa_limit = nvidia && gqa_ratio <= 4 ? 16 : INT_MAX;
|
||||
const bool use_gqa_opt = mask && max_bias == 0.0f && Q->ne[1] <= gqa_limit && K->ne[1] % FATTN_KQ_STRIDE == 0;
|
||||
|
||||
- if constexpr (DV == 512) {
|
||||
+ if constexpr (DKQ == 576) {
|
||||
if (use_gqa_opt && gqa_ratio % 16 == 0) {
|
||||
launch_fattn_tile_switch_ncols1<DKQ, DV, 16, use_logit_softcap>(ctx, dst);
|
||||
return;
|
||||
@@ -1205,7 +1223,7 @@ static void launch_fattn_tile_switch_ncols2(ggml_backend_cuda_context & ctx, ggm
|
||||
}
|
||||
}
|
||||
|
||||
- if constexpr (DV <= 256) {
|
||||
+ if constexpr (DKQ <= 512) {
|
||||
if (use_gqa_opt && gqa_ratio % 8 == 0) {
|
||||
launch_fattn_tile_switch_ncols1<DKQ, DV, 8, use_logit_softcap>(ctx, dst);
|
||||
return;
|
||||
@@ -1216,13 +1234,15 @@ static void launch_fattn_tile_switch_ncols2(ggml_backend_cuda_context & ctx, ggm
|
||||
return;
|
||||
}
|
||||
|
||||
- if (use_gqa_opt && gqa_ratio % 2 == 0) {
|
||||
- launch_fattn_tile_switch_ncols1<DKQ, DV, 2, use_logit_softcap>(ctx, dst);
|
||||
+ if constexpr (DV <= 256) {
|
||||
+ if (use_gqa_opt && gqa_ratio % 2 == 0) {
|
||||
+ launch_fattn_tile_switch_ncols1<DKQ, DV, 2, use_logit_softcap>(ctx, dst);
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ launch_fattn_tile_switch_ncols1<DKQ, DV, 1, use_logit_softcap>(ctx, dst);
|
||||
return;
|
||||
}
|
||||
-
|
||||
- launch_fattn_tile_switch_ncols1<DKQ, DV, 1, use_logit_softcap>(ctx, dst);
|
||||
- return;
|
||||
}
|
||||
GGML_ABORT("fatal error");
|
||||
}
|
||||
@@ -1257,4 +1277,5 @@ extern DECL_FATTN_TILE_CASE( 96, 96);
|
||||
extern DECL_FATTN_TILE_CASE(112, 112);
|
||||
extern DECL_FATTN_TILE_CASE(128, 128);
|
||||
extern DECL_FATTN_TILE_CASE(256, 256);
|
||||
+extern DECL_FATTN_TILE_CASE(512, 512);
|
||||
extern DECL_FATTN_TILE_CASE(576, 512);
|
||||
diff --git a/ggml/src/ggml-cuda/fattn.cu b/ggml/src/ggml-cuda/fattn.cu
|
||||
index 1693479cb..0b7f9ae07 100644
|
||||
--- a/ggml/src/ggml-cuda/fattn.cu
|
||||
+++ b/ggml/src/ggml-cuda/fattn.cu
|
||||
@@ -110,6 +110,10 @@ static void ggml_cuda_flash_attn_ext_mma_f16(ggml_backend_cuda_context & ctx, gg
|
||||
GGML_ASSERT(V->ne[0] == 256);
|
||||
ggml_cuda_flash_attn_ext_mma_f16_switch_ncols2<256, 256>(ctx, dst);
|
||||
break;
|
||||
+ case 512:
|
||||
+ GGML_ASSERT(V->ne[0] == 512);
|
||||
+ ggml_cuda_flash_attn_ext_mma_f16_switch_ncols2<512, 512>(ctx, dst);
|
||||
+ break;
|
||||
case 576: {
|
||||
// For Deepseek/GLM4, go straight to the ncols1 switch to avoid compiling unnecessary kernels.
|
||||
GGML_ASSERT(V->ne[0] == 512);
|
||||
@@ -251,6 +255,11 @@ static best_fattn_kernel ggml_cuda_get_best_fattn_kernel(const int device, const
|
||||
return BEST_FATTN_KERNEL_NONE;
|
||||
}
|
||||
break;
|
||||
+ case 512:
|
||||
+ if (!gqa_opt_applies) {
|
||||
+ return BEST_FATTN_KERNEL_NONE;
|
||||
+ }
|
||||
+ break;
|
||||
case 576:
|
||||
if (V->ne[0] != 512) {
|
||||
return BEST_FATTN_KERNEL_NONE;
|
||||
@@ -334,7 +343,7 @@ static best_fattn_kernel ggml_cuda_get_best_fattn_kernel(const int device, const
|
||||
}
|
||||
|
||||
// Use the WMMA kernel if possible:
|
||||
- if (ggml_cuda_should_use_wmma_fattn(cc) && K->ne[1] % FATTN_KQ_STRIDE == 0 && Q->ne[0] != 40 && Q->ne[0] != 72 && Q->ne[0] != 576) {
|
||||
+ if (ggml_cuda_should_use_wmma_fattn(cc) && K->ne[1] % FATTN_KQ_STRIDE == 0 && Q->ne[0] != 40 && Q->ne[0] != 72 && Q->ne[0] != 512 && Q->ne[0] != 576) {
|
||||
if (can_use_vector_kernel && Q->ne[1] <= 2) {
|
||||
return BEST_FATTN_KERNEL_VEC;
|
||||
}
|
||||
diff --git a/ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-ncols1_1-ncols2_8.cu b/ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-ncols1_1-ncols2_8.cu
|
||||
index dc1682902..22d383173 100644
|
||||
--- a/ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-ncols1_1-ncols2_8.cu
|
||||
+++ b/ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-ncols1_1-ncols2_8.cu
|
||||
@@ -8,3 +8,4 @@ DECL_FATTN_MMA_F16_CASE(96, 96, 1, 8);
|
||||
DECL_FATTN_MMA_F16_CASE(112, 112, 1, 8);
|
||||
DECL_FATTN_MMA_F16_CASE(128, 128, 1, 8);
|
||||
DECL_FATTN_MMA_F16_CASE(256, 256, 1, 8);
|
||||
+DECL_FATTN_MMA_F16_CASE(512, 512, 1, 8);
|
||||
diff --git a/ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-ncols1_16-ncols2_4.cu b/ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-ncols1_16-ncols2_4.cu
|
||||
index 517993cb0..d2415bfa9 100644
|
||||
--- a/ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-ncols1_16-ncols2_4.cu
|
||||
+++ b/ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-ncols1_16-ncols2_4.cu
|
||||
@@ -8,4 +8,5 @@ DECL_FATTN_MMA_F16_CASE(96, 96, 16, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(112, 112, 16, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(128, 128, 16, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(256, 256, 16, 4);
|
||||
+DECL_FATTN_MMA_F16_CASE(512, 512, 16, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(576, 512, 16, 4);
|
||||
diff --git a/ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-ncols1_2-ncols2_4.cu b/ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-ncols1_2-ncols2_4.cu
|
||||
index 97b19c67a..8eec1d74e 100644
|
||||
--- a/ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-ncols1_2-ncols2_4.cu
|
||||
+++ b/ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-ncols1_2-ncols2_4.cu
|
||||
@@ -8,4 +8,5 @@ DECL_FATTN_MMA_F16_CASE(96, 96, 2, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(112, 112, 2, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(128, 128, 2, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(256, 256, 2, 4);
|
||||
+DECL_FATTN_MMA_F16_CASE(512, 512, 2, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(576, 512, 2, 4);
|
||||
diff --git a/ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-ncols1_2-ncols2_8.cu b/ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-ncols1_2-ncols2_8.cu
|
||||
index 163b1d939..84b674cd0 100644
|
||||
--- a/ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-ncols1_2-ncols2_8.cu
|
||||
+++ b/ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-ncols1_2-ncols2_8.cu
|
||||
@@ -8,3 +8,4 @@ DECL_FATTN_MMA_F16_CASE(96, 96, 2, 8);
|
||||
DECL_FATTN_MMA_F16_CASE(112, 112, 2, 8);
|
||||
DECL_FATTN_MMA_F16_CASE(128, 128, 2, 8);
|
||||
DECL_FATTN_MMA_F16_CASE(256, 256, 2, 8);
|
||||
+DECL_FATTN_MMA_F16_CASE(512, 512, 2, 8);
|
||||
diff --git a/ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-ncols1_4-ncols2_4.cu b/ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-ncols1_4-ncols2_4.cu
|
||||
index 989626dfa..3475dfea0 100644
|
||||
--- a/ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-ncols1_4-ncols2_4.cu
|
||||
+++ b/ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-ncols1_4-ncols2_4.cu
|
||||
@@ -8,4 +8,5 @@ DECL_FATTN_MMA_F16_CASE(96, 96, 4, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(112, 112, 4, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(128, 128, 4, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(256, 256, 4, 4);
|
||||
+DECL_FATTN_MMA_F16_CASE(512, 512, 4, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(576, 512, 4, 4);
|
||||
diff --git a/ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-ncols1_4-ncols2_8.cu b/ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-ncols1_4-ncols2_8.cu
|
||||
index bad296b41..5906398db 100644
|
||||
--- a/ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-ncols1_4-ncols2_8.cu
|
||||
+++ b/ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-ncols1_4-ncols2_8.cu
|
||||
@@ -8,3 +8,4 @@ DECL_FATTN_MMA_F16_CASE(96, 96, 4, 8);
|
||||
DECL_FATTN_MMA_F16_CASE(112, 112, 4, 8);
|
||||
DECL_FATTN_MMA_F16_CASE(128, 128, 4, 8);
|
||||
DECL_FATTN_MMA_F16_CASE(256, 256, 4, 8);
|
||||
+DECL_FATTN_MMA_F16_CASE(512, 512, 4, 8);
|
||||
diff --git a/ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-ncols1_8-ncols2_4.cu b/ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-ncols1_8-ncols2_4.cu
|
||||
index 173de7aac..684cd25ce 100644
|
||||
--- a/ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-ncols1_8-ncols2_4.cu
|
||||
+++ b/ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-ncols1_8-ncols2_4.cu
|
||||
@@ -8,4 +8,5 @@ DECL_FATTN_MMA_F16_CASE(96, 96, 8, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(112, 112, 8, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(128, 128, 8, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(256, 256, 8, 4);
|
||||
+DECL_FATTN_MMA_F16_CASE(512, 512, 8, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(576, 512, 8, 4);
|
||||
diff --git a/ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-ncols1_8-ncols2_8.cu b/ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-ncols1_8-ncols2_8.cu
|
||||
index 680a13ca6..4bc60d62f 100644
|
||||
--- a/ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-ncols1_8-ncols2_8.cu
|
||||
+++ b/ggml/src/ggml-cuda/template-instances/fattn-mma-f16-instance-ncols1_8-ncols2_8.cu
|
||||
@@ -8,3 +8,4 @@ DECL_FATTN_MMA_F16_CASE(96, 96, 8, 8);
|
||||
DECL_FATTN_MMA_F16_CASE(112, 112, 8, 8);
|
||||
DECL_FATTN_MMA_F16_CASE(128, 128, 8, 8);
|
||||
DECL_FATTN_MMA_F16_CASE(256, 256, 8, 8);
|
||||
+DECL_FATTN_MMA_F16_CASE(512, 512, 8, 8);
|
||||
diff --git a/ggml/src/ggml-cuda/template-instances/fattn-tile-instance-dkq512-dv512.cu b/ggml/src/ggml-cuda/template-instances/fattn-tile-instance-dkq512-dv512.cu
|
||||
new file mode 100644
|
||||
index 000000000..7c61d8d2e
|
||||
--- /dev/null
|
||||
+++ b/ggml/src/ggml-cuda/template-instances/fattn-tile-instance-dkq512-dv512.cu
|
||||
@@ -0,0 +1,5 @@
|
||||
+// This file has been autogenerated by generate_cu_files.py, do not edit manually.
|
||||
+
|
||||
+#include "../fattn-tile.cuh"
|
||||
+
|
||||
+DECL_FATTN_TILE_CASE(512, 512);
|
||||
diff --git a/ggml/src/ggml-metal/ggml-metal-device.m b/ggml/src/ggml-metal/ggml-metal-device.m
|
||||
index 4e5acfbe5..11457f2b1 100644
|
||||
--- a/ggml/src/ggml-metal/ggml-metal-device.m
|
||||
+++ b/ggml/src/ggml-metal/ggml-metal-device.m
|
||||
@@ -1083,6 +1083,7 @@ bool ggml_metal_device_supports_op(ggml_metal_device_t dev, const struct ggml_te
|
||||
op->src[0]->ne[0] != 128 &&
|
||||
op->src[0]->ne[0] != 192 &&
|
||||
op->src[0]->ne[0] != 256 &&
|
||||
+ op->src[0]->ne[0] != 512 &&
|
||||
op->src[0]->ne[0] != 576) {
|
||||
return false;
|
||||
}
|
||||
diff --git a/ggml/src/ggml-metal/ggml-metal.metal b/ggml/src/ggml-metal/ggml-metal.metal
|
||||
index b14a0000c..398c80717 100644
|
||||
--- a/ggml/src/ggml-metal/ggml-metal.metal
|
||||
+++ b/ggml/src/ggml-metal/ggml-metal.metal
|
||||
@@ -6276,6 +6276,7 @@ template [[host_name("kernel_flash_attn_ext_f32_dk128_dv128")]] kernel flash_at
|
||||
template [[host_name("kernel_flash_attn_ext_f32_dk192_dv192")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES_F32, float4x4, 1, dequantize_f32, float4x4, 1, dequantize_f32, 192, 192>;
|
||||
template [[host_name("kernel_flash_attn_ext_f32_dk192_dv128")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES_F32, float4x4, 1, dequantize_f32, float4x4, 1, dequantize_f32, 192, 128>;
|
||||
template [[host_name("kernel_flash_attn_ext_f32_dk256_dv256")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES_F32, float4x4, 1, dequantize_f32, float4x4, 1, dequantize_f32, 256, 256>;
|
||||
+template [[host_name("kernel_flash_attn_ext_f32_dk512_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES_F32, float4x4, 1, dequantize_f32, float4x4, 1, dequantize_f32, 512, 512>;
|
||||
template [[host_name("kernel_flash_attn_ext_f32_dk576_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES_F32, float4x4, 1, dequantize_f32, float4x4, 1, dequantize_f32, 576, 512>;
|
||||
|
||||
template [[host_name("kernel_flash_attn_ext_f16_dk32_dv32" )]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, half4x4, 1, dequantize_f16, half4x4, 1, dequantize_f16, 32, 32>;
|
||||
@@ -6290,6 +6291,7 @@ template [[host_name("kernel_flash_attn_ext_f16_dk128_dv128")]] kernel flash_at
|
||||
template [[host_name("kernel_flash_attn_ext_f16_dk192_dv192")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, half4x4, 1, dequantize_f16, half4x4, 1, dequantize_f16, 192, 192>;
|
||||
template [[host_name("kernel_flash_attn_ext_f16_dk192_dv128")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, half4x4, 1, dequantize_f16, half4x4, 1, dequantize_f16, 192, 128>;
|
||||
template [[host_name("kernel_flash_attn_ext_f16_dk256_dv256")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, half4x4, 1, dequantize_f16, half4x4, 1, dequantize_f16, 256, 256>;
|
||||
+template [[host_name("kernel_flash_attn_ext_f16_dk512_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, half4x4, 1, dequantize_f16, half4x4, 1, dequantize_f16, 512, 512>;
|
||||
template [[host_name("kernel_flash_attn_ext_f16_dk576_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, half4x4, 1, dequantize_f16, half4x4, 1, dequantize_f16, 576, 512>;
|
||||
|
||||
#if defined(GGML_METAL_HAS_BF16)
|
||||
@@ -6305,6 +6307,7 @@ template [[host_name("kernel_flash_attn_ext_bf16_dk128_dv128")]] kernel flash_at
|
||||
template [[host_name("kernel_flash_attn_ext_bf16_dk192_dv192")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES_BF, bfloat4x4, 1, dequantize_bf16, bfloat4x4, 1, dequantize_bf16, 192, 192>;
|
||||
template [[host_name("kernel_flash_attn_ext_bf16_dk192_dv128")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES_BF, bfloat4x4, 1, dequantize_bf16, bfloat4x4, 1, dequantize_bf16, 192, 128>;
|
||||
template [[host_name("kernel_flash_attn_ext_bf16_dk256_dv256")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES_BF, bfloat4x4, 1, dequantize_bf16, bfloat4x4, 1, dequantize_bf16, 256, 256>;
|
||||
+template [[host_name("kernel_flash_attn_ext_bf16_dk512_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES_BF, bfloat4x4, 1, dequantize_bf16, bfloat4x4, 1, dequantize_bf16, 512, 512>;
|
||||
template [[host_name("kernel_flash_attn_ext_bf16_dk576_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES_BF, bfloat4x4, 1, dequantize_bf16, bfloat4x4, 1, dequantize_bf16, 576, 512>;
|
||||
#endif
|
||||
|
||||
@@ -6320,6 +6323,7 @@ template [[host_name("kernel_flash_attn_ext_q4_0_dk128_dv128")]] kernel flash_at
|
||||
template [[host_name("kernel_flash_attn_ext_q4_0_dk192_dv192")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q4_0, 2, dequantize_q4_0, block_q4_0, 2, dequantize_q4_0, 192, 192>;
|
||||
template [[host_name("kernel_flash_attn_ext_q4_0_dk192_dv128")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q4_0, 2, dequantize_q4_0, block_q4_0, 2, dequantize_q4_0, 192, 128>;
|
||||
template [[host_name("kernel_flash_attn_ext_q4_0_dk256_dv256")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q4_0, 2, dequantize_q4_0, block_q4_0, 2, dequantize_q4_0, 256, 256>;
|
||||
+template [[host_name("kernel_flash_attn_ext_q4_0_dk512_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q4_0, 2, dequantize_q4_0, block_q4_0, 2, dequantize_q4_0, 512, 512>;
|
||||
template [[host_name("kernel_flash_attn_ext_q4_0_dk576_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q4_0, 2, dequantize_q4_0, block_q4_0, 2, dequantize_q4_0, 576, 512>;
|
||||
|
||||
template [[host_name("kernel_flash_attn_ext_q4_1_dk32_dv32" )]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q4_1, 2, dequantize_q4_1, block_q4_1, 2, dequantize_q4_1, 32, 32>;
|
||||
@@ -6334,6 +6338,7 @@ template [[host_name("kernel_flash_attn_ext_q4_1_dk128_dv128")]] kernel flash_at
|
||||
template [[host_name("kernel_flash_attn_ext_q4_1_dk192_dv192")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q4_1, 2, dequantize_q4_1, block_q4_1, 2, dequantize_q4_1, 192, 192>;
|
||||
template [[host_name("kernel_flash_attn_ext_q4_1_dk192_dv128")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q4_1, 2, dequantize_q4_1, block_q4_1, 2, dequantize_q4_1, 192, 128>;
|
||||
template [[host_name("kernel_flash_attn_ext_q4_1_dk256_dv256")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q4_1, 2, dequantize_q4_1, block_q4_1, 2, dequantize_q4_1, 256, 256>;
|
||||
+template [[host_name("kernel_flash_attn_ext_q4_1_dk512_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q4_1, 2, dequantize_q4_1, block_q4_1, 2, dequantize_q4_1, 512, 512>;
|
||||
template [[host_name("kernel_flash_attn_ext_q4_1_dk576_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q4_1, 2, dequantize_q4_1, block_q4_1, 2, dequantize_q4_1, 576, 512>;
|
||||
|
||||
template [[host_name("kernel_flash_attn_ext_q5_0_dk32_dv32" )]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_0, 2, dequantize_q5_0, block_q5_0, 2, dequantize_q5_0, 32, 32>;
|
||||
@@ -6348,6 +6353,7 @@ template [[host_name("kernel_flash_attn_ext_q5_0_dk128_dv128")]] kernel flash_at
|
||||
template [[host_name("kernel_flash_attn_ext_q5_0_dk192_dv192")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_0, 2, dequantize_q5_0, block_q5_0, 2, dequantize_q5_0, 192, 192>;
|
||||
template [[host_name("kernel_flash_attn_ext_q5_0_dk192_dv128")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_0, 2, dequantize_q5_0, block_q5_0, 2, dequantize_q5_0, 192, 128>;
|
||||
template [[host_name("kernel_flash_attn_ext_q5_0_dk256_dv256")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_0, 2, dequantize_q5_0, block_q5_0, 2, dequantize_q5_0, 256, 256>;
|
||||
+template [[host_name("kernel_flash_attn_ext_q5_0_dk512_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_0, 2, dequantize_q5_0, block_q5_0, 2, dequantize_q5_0, 512, 512>;
|
||||
template [[host_name("kernel_flash_attn_ext_q5_0_dk576_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_0, 2, dequantize_q5_0, block_q5_0, 2, dequantize_q5_0, 576, 512>;
|
||||
|
||||
template [[host_name("kernel_flash_attn_ext_q5_1_dk32_dv32" )]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_1, 2, dequantize_q5_1, block_q5_1, 2, dequantize_q5_1, 32, 32>;
|
||||
@@ -6362,6 +6368,7 @@ template [[host_name("kernel_flash_attn_ext_q5_1_dk128_dv128")]] kernel flash_at
|
||||
template [[host_name("kernel_flash_attn_ext_q5_1_dk192_dv192")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_1, 2, dequantize_q5_1, block_q5_1, 2, dequantize_q5_1, 192, 192>;
|
||||
template [[host_name("kernel_flash_attn_ext_q5_1_dk192_dv128")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_1, 2, dequantize_q5_1, block_q5_1, 2, dequantize_q5_1, 192, 128>;
|
||||
template [[host_name("kernel_flash_attn_ext_q5_1_dk256_dv256")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_1, 2, dequantize_q5_1, block_q5_1, 2, dequantize_q5_1, 256, 256>;
|
||||
+template [[host_name("kernel_flash_attn_ext_q5_1_dk512_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_1, 2, dequantize_q5_1, block_q5_1, 2, dequantize_q5_1, 512, 512>;
|
||||
template [[host_name("kernel_flash_attn_ext_q5_1_dk576_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_1, 2, dequantize_q5_1, block_q5_1, 2, dequantize_q5_1, 576, 512>;
|
||||
|
||||
template [[host_name("kernel_flash_attn_ext_q8_0_dk32_dv32" )]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q8_0, 2, dequantize_q8_0, block_q8_0, 2, dequantize_q8_0, 32, 32>;
|
||||
@@ -6376,6 +6383,7 @@ template [[host_name("kernel_flash_attn_ext_q8_0_dk128_dv128")]] kernel flash_at
|
||||
template [[host_name("kernel_flash_attn_ext_q8_0_dk192_dv192")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q8_0, 2, dequantize_q8_0, block_q8_0, 2, dequantize_q8_0, 192, 192>;
|
||||
template [[host_name("kernel_flash_attn_ext_q8_0_dk192_dv128")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q8_0, 2, dequantize_q8_0, block_q8_0, 2, dequantize_q8_0, 192, 128>;
|
||||
template [[host_name("kernel_flash_attn_ext_q8_0_dk256_dv256")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q8_0, 2, dequantize_q8_0, block_q8_0, 2, dequantize_q8_0, 256, 256>;
|
||||
+template [[host_name("kernel_flash_attn_ext_q8_0_dk512_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q8_0, 2, dequantize_q8_0, block_q8_0, 2, dequantize_q8_0, 512, 512>;
|
||||
template [[host_name("kernel_flash_attn_ext_q8_0_dk576_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q8_0, 2, dequantize_q8_0, block_q8_0, 2, dequantize_q8_0, 576, 512>;
|
||||
|
||||
#undef FA_TYPES
|
||||
@@ -6990,6 +6998,17 @@ template [[host_name("kernel_flash_attn_ext_vec_q5_0_dk256_dv256")]] kernel flas
|
||||
template [[host_name("kernel_flash_attn_ext_vec_q5_1_dk256_dv256")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES, block_q5_1, 8, dequantize_q5_1_t4, block_q5_1, 8, dequantize_q5_1_t4, 256, 256, 1>;
|
||||
template [[host_name("kernel_flash_attn_ext_vec_q8_0_dk256_dv256")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES, block_q8_0, 8, dequantize_q8_0_t4, block_q8_0, 8, dequantize_q8_0_t4, 256, 256, 1>;
|
||||
|
||||
+template [[host_name("kernel_flash_attn_ext_vec_f32_dk512_dv512")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES_F32, float4, 1, dequantize_f32_t4, float4, 1, dequantize_f32_t4, 512, 512, 1>;
|
||||
+template [[host_name("kernel_flash_attn_ext_vec_f16_dk512_dv512")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES, half4, 1, dequantize_f16_t4, half4, 1, dequantize_f16_t4, 512, 512, 1>;
|
||||
+#if defined(GGML_METAL_HAS_BF16)
|
||||
+template [[host_name("kernel_flash_attn_ext_vec_bf16_dk512_dv512")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES, bfloat4, 1, dequantize_bf16_t4, bfloat4, 1, dequantize_bf16_t4, 512, 512, 1>;
|
||||
+#endif
|
||||
+template [[host_name("kernel_flash_attn_ext_vec_q4_0_dk512_dv512")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES, block_q4_0, 8, dequantize_q4_0_t4, block_q4_0, 8, dequantize_q4_0_t4, 512, 512, 1>;
|
||||
+template [[host_name("kernel_flash_attn_ext_vec_q4_1_dk512_dv512")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES, block_q4_1, 8, dequantize_q4_1_t4, block_q4_1, 8, dequantize_q4_1_t4, 512, 512, 1>;
|
||||
+template [[host_name("kernel_flash_attn_ext_vec_q5_0_dk512_dv512")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES, block_q5_0, 8, dequantize_q5_0_t4, block_q5_0, 8, dequantize_q5_0_t4, 512, 512, 1>;
|
||||
+template [[host_name("kernel_flash_attn_ext_vec_q5_1_dk512_dv512")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES, block_q5_1, 8, dequantize_q5_1_t4, block_q5_1, 8, dequantize_q5_1_t4, 512, 512, 1>;
|
||||
+template [[host_name("kernel_flash_attn_ext_vec_q8_0_dk512_dv512")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES, block_q8_0, 8, dequantize_q8_0_t4, block_q8_0, 8, dequantize_q8_0_t4, 512, 512, 1>;
|
||||
+
|
||||
template [[host_name("kernel_flash_attn_ext_vec_f32_dk576_dv512")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES_F32, float4, 1, dequantize_f32_t4, float4, 1, dequantize_f32_t4, 576, 512, 2>;
|
||||
template [[host_name("kernel_flash_attn_ext_vec_f16_dk576_dv512")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES, half4, 1, dequantize_f16_t4, half4, 1, dequantize_f16_t4, 576, 512, 2>;
|
||||
#if defined(GGML_METAL_HAS_BF16)
|
||||
@@ -210,6 +210,18 @@ func NewLlamaServer(systemInfo ml.SystemInfo, gpus []ml.DeviceInfo, modelPath st
|
||||
fa = false
|
||||
}
|
||||
|
||||
// Gemma 4's 512-dim attention heads require MMA FA kernels (Turing+, compute >= 7.5).
|
||||
// Older CUDA GPUs only have tile/vec FA kernels which abort on dk512 non-GQA attention.
|
||||
if fa && f.KV().Architecture() == "gemma4" {
|
||||
for _, gpu := range gpus {
|
||||
if gpu.Library == "CUDA" && (gpu.ComputeMajor < 7 || (gpu.ComputeMajor == 7 && gpu.ComputeMinor < 5)) {
|
||||
slog.Debug("disabling flash attention for gemma4 on pre-Turing GPU", "compute", fmt.Sprintf("%d.%d", gpu.ComputeMajor, gpu.ComputeMinor))
|
||||
fa = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kvct := strings.ToLower(envconfig.KvCacheType())
|
||||
|
||||
if tok == nil {
|
||||
|
||||
+23
@@ -65,12 +65,35 @@ static cudaError_t cudaMemsetAsyncReserve ( void* devPtr, int value, size_t coun
|
||||
}
|
||||
}
|
||||
|
||||
static cublasStatus_t cublasGemmBatchedExReserve(
|
||||
cublasHandle_t handle, cublasOperation_t transa, cublasOperation_t transb,
|
||||
int m, int n, int k,
|
||||
const void *alpha,
|
||||
const void *const Aarray[], cudaDataType_t Atype, int lda,
|
||||
const void *const Barray[], cudaDataType_t Btype, int ldb,
|
||||
const void *beta,
|
||||
void *const Carray[], cudaDataType_t Ctype, int ldc,
|
||||
int batchCount,
|
||||
cublasComputeType_t computeType, cublasGemmAlgo_t algo) {
|
||||
if (!reserving_graph) {
|
||||
return cublasGemmBatchedEx(handle, transa, transb, m, n, k,
|
||||
alpha, const_cast<const void **>(Aarray), Atype, lda,
|
||||
const_cast<const void **>(Barray), Btype, ldb,
|
||||
beta, const_cast<void **>(Carray), Ctype, ldc,
|
||||
batchCount, computeType, algo);
|
||||
} else {
|
||||
return CUBLAS_STATUS_SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
#undef cudaMemcpyAsync
|
||||
#define cudaMemcpyAsync cudaMemcpyAsyncReserve
|
||||
#undef cudaMemcpy2DAsync
|
||||
#define cudaMemcpy2DAsync cudaMemcpy2DAsyncReserve
|
||||
#undef cudaMemsetAsync
|
||||
#define cudaMemsetAsync cudaMemsetAsyncReserve
|
||||
#undef cublasGemmBatchedEx
|
||||
#define cublasGemmBatchedEx cublasGemmBatchedExReserve
|
||||
|
||||
#define STRINGIZE_IMPL(...) #__VA_ARGS__
|
||||
#define STRINGIZE(...) STRINGIZE_IMPL(__VA_ARGS__)
|
||||
|
||||
+25
-1
@@ -66,6 +66,11 @@ static constexpr __host__ __device__ fattn_mma_config ggml_cuda_fattn_mma_get_co
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(256, 256, 32, 128, 2, 32, 128, 128, 128, 2, true);
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(256, 256, 64, 128, 2, 32, 128, 128, 128, 2, true);
|
||||
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(512, 512, 8, 64, 4, 32, 256, 256, 128, 1, false);
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(512, 512, 16, 64, 4, 32, 256, 256, 128, 1, false);
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(512, 512, 32, 128, 2, 32, 128, 128, 128, 1, false);
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(512, 512, 64, 256, 1, 32, 128, 128, 128, 1, false);
|
||||
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(576, 512, 4, 64, 4, 32, 288, 256, 128, 1, false);
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(576, 512, 8, 64, 4, 32, 288, 256, 128, 1, true);
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(576, 512, 16, 64, 4, 32, 288, 256, 128, 1, false);
|
||||
@@ -81,6 +86,11 @@ static constexpr __host__ __device__ fattn_mma_config ggml_cuda_fattn_mma_get_co
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(256, 256, 32, 128, 2, 64, 128, 128, 64, 2, true);
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(256, 256, 64, 128, 2, 64, 128, 128, 64, 2, true);
|
||||
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(512, 512, 8, 64, 4, 32, 96, 64, 128, 1, false);
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(512, 512, 16, 64, 4, 32, 96, 64, 128, 1, false);
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(512, 512, 32, 128, 2, 32, 128, 128, 128, 1, false);
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(512, 512, 64, 256, 1, 32, 128, 128, 128, 1, false);
|
||||
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(576, 512, 4, 64, 4, 32, 96, 64, 128, 1, false);
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(576, 512, 8, 64, 4, 32, 96, 64, 128, 1, true);
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(576, 512, 16, 64, 4, 32, 96, 64, 128, 1, false);
|
||||
@@ -91,6 +101,11 @@ static constexpr __host__ __device__ fattn_mma_config ggml_cuda_fattn_mma_get_co
|
||||
}
|
||||
|
||||
static constexpr __host__ __device__ fattn_mma_config ggml_cuda_fattn_mma_get_config_volta(const int DKQ, const int DV, const int ncols) {
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(512, 512, 8, 64, 4, 32, 256, 256, 64, 1, false);
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(512, 512, 16, 64, 4, 32, 256, 256, 64, 1, false);
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(512, 512, 32, 128, 2, 32, 128, 128, 64, 1, false);
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(512, 512, 64, 256, 1, 32, 128, 128, 64, 1, false);
|
||||
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(576, 512, 4, 64, 4, 32, 288, 256, 64, 1, false);
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(576, 512, 8, 64, 4, 32, 288, 256, 64, 1, true);
|
||||
GGML_CUDA_FATTN_MMA_CONFIG_CASE(576, 512, 16, 64, 4, 32, 288, 256, 64, 1, false);
|
||||
@@ -1361,7 +1376,7 @@ static __global__ void flash_attn_ext_f16(
|
||||
#if defined(FLASH_ATTN_AVAILABLE) && (defined(VOLTA_MMA_AVAILABLE) || defined(TURING_MMA_AVAILABLE))
|
||||
|
||||
// Skip unused kernel variants for faster compilation:
|
||||
if (use_logit_softcap && !(DKQ == 128 || DKQ == 256)) {
|
||||
if (use_logit_softcap && !(DKQ == 128 || DKQ == 256 || DKQ == 512)) {
|
||||
NO_DEVICE_CODE;
|
||||
return;
|
||||
}
|
||||
@@ -1600,6 +1615,15 @@ DECL_FATTN_MMA_F16_CASE_ALL_NCOLS2(112, 112, 64)
|
||||
DECL_FATTN_MMA_F16_CASE_ALL_NCOLS2(128, 128, 64)
|
||||
DECL_FATTN_MMA_F16_CASE_ALL_NCOLS2(256, 256, 64)
|
||||
|
||||
extern DECL_FATTN_MMA_F16_CASE(512, 512, 2, 4);
|
||||
extern DECL_FATTN_MMA_F16_CASE(512, 512, 4, 4);
|
||||
extern DECL_FATTN_MMA_F16_CASE(512, 512, 8, 4);
|
||||
extern DECL_FATTN_MMA_F16_CASE(512, 512, 16, 4);
|
||||
extern DECL_FATTN_MMA_F16_CASE(512, 512, 1, 8);
|
||||
extern DECL_FATTN_MMA_F16_CASE(512, 512, 2, 8);
|
||||
extern DECL_FATTN_MMA_F16_CASE(512, 512, 4, 8);
|
||||
extern DECL_FATTN_MMA_F16_CASE(512, 512, 8, 8);
|
||||
|
||||
// The number of viable configurations for Deepseek is very limited:
|
||||
extern DECL_FATTN_MMA_F16_CASE(576, 512, 1, 16);
|
||||
extern DECL_FATTN_MMA_F16_CASE(576, 512, 2, 16);
|
||||
|
||||
@@ -38,6 +38,10 @@ void ggml_cuda_flash_attn_ext_tile(ggml_backend_cuda_context & ctx, ggml_tensor
|
||||
GGML_ASSERT(V->ne[0] == K->ne[0]);
|
||||
ggml_cuda_flash_attn_ext_tile_case<256, 256>(ctx, dst);
|
||||
} break;
|
||||
case 512: {
|
||||
GGML_ASSERT(V->ne[0] == K->ne[0]);
|
||||
ggml_cuda_flash_attn_ext_tile_case<512, 512>(ctx, dst);
|
||||
} break;
|
||||
case 576: {
|
||||
GGML_ASSERT(V->ne[0] == 512);
|
||||
ggml_cuda_flash_attn_ext_tile_case<576, 512>(ctx, dst);
|
||||
|
||||
+29
-8
@@ -68,6 +68,10 @@ static constexpr __host__ __device__ uint32_t ggml_cuda_fattn_tile_get_config_nv
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(256, 256, 16, 256, 2, 64, 64)
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(256, 256, 32, 256, 2, 64, 64)
|
||||
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(512, 512, 4, 128, 2, 64, 64)
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(512, 512, 8, 256, 2, 64, 64)
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(512, 512, 16, 256, 2, 64, 64)
|
||||
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(576, 512, 4, 128, 2, 64, 64)
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(576, 512, 8, 256, 2, 64, 64)
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(576, 512, 16, 256, 2, 64, 64)
|
||||
@@ -124,6 +128,10 @@ static constexpr __host__ __device__ uint32_t ggml_cuda_fattn_tile_get_config_nv
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(256, 256, 16, 256, 2, 32, 128)
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(256, 256, 32, 256, 2, 32, 64)
|
||||
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(512, 512, 4, 128, 2, 32, 64)
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(512, 512, 8, 256, 2, 32, 64)
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(512, 512, 16, 256, 2, 32, 64)
|
||||
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(576, 512, 4, 128, 2, 32, 64)
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(576, 512, 8, 256, 2, 32, 64)
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(576, 512, 16, 256, 2, 32, 64)
|
||||
@@ -187,6 +195,11 @@ static constexpr __host__ __device__ uint32_t ggml_cuda_fattn_tile_get_config_am
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(256, 256, 16, 256, 2, 32, 128)
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(256, 256, 32, 256, 2, 32, 128)
|
||||
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(512, 512, 4, 128, 2, 64, 64)
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(512, 512, 8, 256, 2, 64, 64)
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(512, 512, 16, 256, 2, 64, 64)
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(512, 512, 32, 512, 1, 128, 64)
|
||||
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(576, 512, 4, 128, 2, 64, 64)
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(576, 512, 8, 256, 2, 64, 64)
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(576, 512, 16, 256, 2, 64, 64)
|
||||
@@ -251,6 +264,11 @@ static constexpr __host__ __device__ uint32_t ggml_cuda_fattn_tile_get_config_am
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(256, 256, 16, 256, 5, 32, 256)
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(256, 256, 32, 256, 3, 64, 128)
|
||||
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(512, 512, 4, 128, 2, 64, 64)
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(512, 512, 8, 256, 2, 64, 64)
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(512, 512, 16, 256, 4, 64, 64)
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(512, 512, 32, 256, 2, 128, 64)
|
||||
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(576, 512, 4, 128, 2, 64, 64)
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(576, 512, 8, 256, 2, 64, 64)
|
||||
GGML_CUDA_FATTN_TILE_CONFIG_CASE(576, 512, 16, 256, 4, 64, 64)
|
||||
@@ -767,7 +785,7 @@ static __global__ void flash_attn_tile(
|
||||
#ifdef GGML_USE_WMMA_FATTN
|
||||
(ncols2 != 1 && DV != 40 && DV != 72 && DV != 512) ||
|
||||
#endif // GGML_USE_WMMA_FATTN
|
||||
(use_logit_softcap && !(DV == 128 || DV == 256))
|
||||
(use_logit_softcap && !(DV == 128 || DV == 256 || DV == 512))
|
||||
) {
|
||||
GGML_UNUSED_VARS(Q, K, V, mask, sinks, KV_max, dst, dst_meta, scale,
|
||||
max_bias, m0, m1, n_head_log2, logit_softcap,
|
||||
@@ -1190,7 +1208,7 @@ static void launch_fattn_tile_switch_ncols2(ggml_backend_cuda_context & ctx, ggm
|
||||
const int gqa_limit = nvidia && gqa_ratio <= 4 ? 16 : INT_MAX;
|
||||
const bool use_gqa_opt = mask && max_bias == 0.0f && Q->ne[1] <= gqa_limit && K->ne[1] % FATTN_KQ_STRIDE == 0;
|
||||
|
||||
if constexpr (DV == 512) {
|
||||
if constexpr (DKQ == 576) {
|
||||
if (use_gqa_opt && gqa_ratio % 16 == 0) {
|
||||
launch_fattn_tile_switch_ncols1<DKQ, DV, 16, use_logit_softcap>(ctx, dst);
|
||||
return;
|
||||
@@ -1205,7 +1223,7 @@ static void launch_fattn_tile_switch_ncols2(ggml_backend_cuda_context & ctx, ggm
|
||||
}
|
||||
}
|
||||
|
||||
if constexpr (DV <= 256) {
|
||||
if constexpr (DKQ <= 512) {
|
||||
if (use_gqa_opt && gqa_ratio % 8 == 0) {
|
||||
launch_fattn_tile_switch_ncols1<DKQ, DV, 8, use_logit_softcap>(ctx, dst);
|
||||
return;
|
||||
@@ -1216,13 +1234,15 @@ static void launch_fattn_tile_switch_ncols2(ggml_backend_cuda_context & ctx, ggm
|
||||
return;
|
||||
}
|
||||
|
||||
if (use_gqa_opt && gqa_ratio % 2 == 0) {
|
||||
launch_fattn_tile_switch_ncols1<DKQ, DV, 2, use_logit_softcap>(ctx, dst);
|
||||
if constexpr (DV <= 256) {
|
||||
if (use_gqa_opt && gqa_ratio % 2 == 0) {
|
||||
launch_fattn_tile_switch_ncols1<DKQ, DV, 2, use_logit_softcap>(ctx, dst);
|
||||
return;
|
||||
}
|
||||
|
||||
launch_fattn_tile_switch_ncols1<DKQ, DV, 1, use_logit_softcap>(ctx, dst);
|
||||
return;
|
||||
}
|
||||
|
||||
launch_fattn_tile_switch_ncols1<DKQ, DV, 1, use_logit_softcap>(ctx, dst);
|
||||
return;
|
||||
}
|
||||
GGML_ABORT("fatal error");
|
||||
}
|
||||
@@ -1257,4 +1277,5 @@ extern DECL_FATTN_TILE_CASE( 96, 96);
|
||||
extern DECL_FATTN_TILE_CASE(112, 112);
|
||||
extern DECL_FATTN_TILE_CASE(128, 128);
|
||||
extern DECL_FATTN_TILE_CASE(256, 256);
|
||||
extern DECL_FATTN_TILE_CASE(512, 512);
|
||||
extern DECL_FATTN_TILE_CASE(576, 512);
|
||||
+10
-1
@@ -110,6 +110,10 @@ static void ggml_cuda_flash_attn_ext_mma_f16(ggml_backend_cuda_context & ctx, gg
|
||||
GGML_ASSERT(V->ne[0] == 256);
|
||||
ggml_cuda_flash_attn_ext_mma_f16_switch_ncols2<256, 256>(ctx, dst);
|
||||
break;
|
||||
case 512:
|
||||
GGML_ASSERT(V->ne[0] == 512);
|
||||
ggml_cuda_flash_attn_ext_mma_f16_switch_ncols2<512, 512>(ctx, dst);
|
||||
break;
|
||||
case 576: {
|
||||
// For Deepseek/GLM4, go straight to the ncols1 switch to avoid compiling unnecessary kernels.
|
||||
GGML_ASSERT(V->ne[0] == 512);
|
||||
@@ -251,6 +255,11 @@ static best_fattn_kernel ggml_cuda_get_best_fattn_kernel(const int device, const
|
||||
return BEST_FATTN_KERNEL_NONE;
|
||||
}
|
||||
break;
|
||||
case 512:
|
||||
if (!gqa_opt_applies) {
|
||||
return BEST_FATTN_KERNEL_NONE;
|
||||
}
|
||||
break;
|
||||
case 576:
|
||||
if (V->ne[0] != 512) {
|
||||
return BEST_FATTN_KERNEL_NONE;
|
||||
@@ -334,7 +343,7 @@ static best_fattn_kernel ggml_cuda_get_best_fattn_kernel(const int device, const
|
||||
}
|
||||
|
||||
// Use the WMMA kernel if possible:
|
||||
if (ggml_cuda_should_use_wmma_fattn(cc) && K->ne[1] % FATTN_KQ_STRIDE == 0 && Q->ne[0] != 40 && Q->ne[0] != 72 && Q->ne[0] != 576) {
|
||||
if (ggml_cuda_should_use_wmma_fattn(cc) && K->ne[1] % FATTN_KQ_STRIDE == 0 && Q->ne[0] != 40 && Q->ne[0] != 72 && Q->ne[0] != 512 && Q->ne[0] != 576) {
|
||||
if (can_use_vector_kernel && Q->ne[1] <= 2) {
|
||||
return BEST_FATTN_KERNEL_VEC;
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -8,3 +8,4 @@ DECL_FATTN_MMA_F16_CASE(96, 96, 1, 8);
|
||||
DECL_FATTN_MMA_F16_CASE(112, 112, 1, 8);
|
||||
DECL_FATTN_MMA_F16_CASE(128, 128, 1, 8);
|
||||
DECL_FATTN_MMA_F16_CASE(256, 256, 1, 8);
|
||||
DECL_FATTN_MMA_F16_CASE(512, 512, 1, 8);
|
||||
Vendored
+1
@@ -8,4 +8,5 @@ DECL_FATTN_MMA_F16_CASE(96, 96, 16, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(112, 112, 16, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(128, 128, 16, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(256, 256, 16, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(512, 512, 16, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(576, 512, 16, 4);
|
||||
Vendored
+1
@@ -8,4 +8,5 @@ DECL_FATTN_MMA_F16_CASE(96, 96, 2, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(112, 112, 2, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(128, 128, 2, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(256, 256, 2, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(512, 512, 2, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(576, 512, 2, 4);
|
||||
Vendored
+1
@@ -8,3 +8,4 @@ DECL_FATTN_MMA_F16_CASE(96, 96, 2, 8);
|
||||
DECL_FATTN_MMA_F16_CASE(112, 112, 2, 8);
|
||||
DECL_FATTN_MMA_F16_CASE(128, 128, 2, 8);
|
||||
DECL_FATTN_MMA_F16_CASE(256, 256, 2, 8);
|
||||
DECL_FATTN_MMA_F16_CASE(512, 512, 2, 8);
|
||||
Vendored
+1
@@ -8,4 +8,5 @@ DECL_FATTN_MMA_F16_CASE(96, 96, 4, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(112, 112, 4, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(128, 128, 4, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(256, 256, 4, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(512, 512, 4, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(576, 512, 4, 4);
|
||||
Vendored
+1
@@ -8,3 +8,4 @@ DECL_FATTN_MMA_F16_CASE(96, 96, 4, 8);
|
||||
DECL_FATTN_MMA_F16_CASE(112, 112, 4, 8);
|
||||
DECL_FATTN_MMA_F16_CASE(128, 128, 4, 8);
|
||||
DECL_FATTN_MMA_F16_CASE(256, 256, 4, 8);
|
||||
DECL_FATTN_MMA_F16_CASE(512, 512, 4, 8);
|
||||
Vendored
+1
@@ -8,4 +8,5 @@ DECL_FATTN_MMA_F16_CASE(96, 96, 8, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(112, 112, 8, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(128, 128, 8, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(256, 256, 8, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(512, 512, 8, 4);
|
||||
DECL_FATTN_MMA_F16_CASE(576, 512, 8, 4);
|
||||
Vendored
+1
@@ -8,3 +8,4 @@ DECL_FATTN_MMA_F16_CASE(96, 96, 8, 8);
|
||||
DECL_FATTN_MMA_F16_CASE(112, 112, 8, 8);
|
||||
DECL_FATTN_MMA_F16_CASE(128, 128, 8, 8);
|
||||
DECL_FATTN_MMA_F16_CASE(256, 256, 8, 8);
|
||||
DECL_FATTN_MMA_F16_CASE(512, 512, 8, 8);
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
// This file has been autogenerated by generate_cu_files.py, do not edit manually.
|
||||
|
||||
#include "../fattn-tile.cuh"
|
||||
|
||||
DECL_FATTN_TILE_CASE(512, 512);
|
||||
@@ -39,6 +39,7 @@
|
||||
#define cublasCreate hipblasCreate
|
||||
#define cublasDestroy hipblasDestroy
|
||||
#define cublasGemmEx hipblasGemmEx
|
||||
#define cublasGemmAlgo_t hipblasGemmAlgo_t
|
||||
#define cublasGemmBatchedEx hipblasGemmBatchedEx
|
||||
#define cublasGemmStridedBatchedEx hipblasGemmStridedBatchedEx
|
||||
#define cublasHandle_t hipblasHandle_t
|
||||
|
||||
@@ -1083,6 +1083,7 @@ bool ggml_metal_device_supports_op(ggml_metal_device_t dev, const struct ggml_te
|
||||
op->src[0]->ne[0] != 128 &&
|
||||
op->src[0]->ne[0] != 192 &&
|
||||
op->src[0]->ne[0] != 256 &&
|
||||
op->src[0]->ne[0] != 512 &&
|
||||
op->src[0]->ne[0] != 576) {
|
||||
return false;
|
||||
}
|
||||
|
||||
+20
-1
@@ -7122,7 +7122,7 @@ kernel void kernel_rope_multi(
|
||||
const int sec_w012 = args.sect_0 + args.sect_1 + args.sect_2; // end of section 2
|
||||
const int sector = ic % sect_dims;
|
||||
|
||||
float theta_base;
|
||||
float theta_base = 0.0;
|
||||
if (FC_rope_is_imrope) {
|
||||
if (sector % 3 == 1 && sector < 1 + 3 * args.sect_1) { // h
|
||||
theta_base = (float) pos[i2 + args.ne02 * 1];
|
||||
@@ -9098,6 +9098,7 @@ template [[host_name("kernel_flash_attn_ext_f32_dk128_dv128")]] kernel flash_at
|
||||
template [[host_name("kernel_flash_attn_ext_f32_dk192_dv192")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES_F32, float4x4, 1, dequantize_f32, float4x4, 1, dequantize_f32, 192, 192>;
|
||||
template [[host_name("kernel_flash_attn_ext_f32_dk192_dv128")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES_F32, float4x4, 1, dequantize_f32, float4x4, 1, dequantize_f32, 192, 128>;
|
||||
template [[host_name("kernel_flash_attn_ext_f32_dk256_dv256")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES_F32, float4x4, 1, dequantize_f32, float4x4, 1, dequantize_f32, 256, 256>;
|
||||
template [[host_name("kernel_flash_attn_ext_f32_dk512_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES_F32, float4x4, 1, dequantize_f32, float4x4, 1, dequantize_f32, 512, 512>;
|
||||
template [[host_name("kernel_flash_attn_ext_f32_dk576_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES_F32, float4x4, 1, dequantize_f32, float4x4, 1, dequantize_f32, 576, 512>;
|
||||
|
||||
template [[host_name("kernel_flash_attn_ext_f16_dk32_dv32" )]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, half4x4, 1, dequantize_f16, half4x4, 1, dequantize_f16, 32, 32>;
|
||||
@@ -9112,6 +9113,7 @@ template [[host_name("kernel_flash_attn_ext_f16_dk128_dv128")]] kernel flash_at
|
||||
template [[host_name("kernel_flash_attn_ext_f16_dk192_dv192")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, half4x4, 1, dequantize_f16, half4x4, 1, dequantize_f16, 192, 192>;
|
||||
template [[host_name("kernel_flash_attn_ext_f16_dk192_dv128")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, half4x4, 1, dequantize_f16, half4x4, 1, dequantize_f16, 192, 128>;
|
||||
template [[host_name("kernel_flash_attn_ext_f16_dk256_dv256")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, half4x4, 1, dequantize_f16, half4x4, 1, dequantize_f16, 256, 256>;
|
||||
template [[host_name("kernel_flash_attn_ext_f16_dk512_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, half4x4, 1, dequantize_f16, half4x4, 1, dequantize_f16, 512, 512>;
|
||||
template [[host_name("kernel_flash_attn_ext_f16_dk576_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, half4x4, 1, dequantize_f16, half4x4, 1, dequantize_f16, 576, 512>;
|
||||
|
||||
#if defined(GGML_METAL_HAS_BF16)
|
||||
@@ -9127,6 +9129,7 @@ template [[host_name("kernel_flash_attn_ext_bf16_dk128_dv128")]] kernel flash_at
|
||||
template [[host_name("kernel_flash_attn_ext_bf16_dk192_dv192")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES_BF, bfloat4x4, 1, dequantize_bf16, bfloat4x4, 1, dequantize_bf16, 192, 192>;
|
||||
template [[host_name("kernel_flash_attn_ext_bf16_dk192_dv128")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES_BF, bfloat4x4, 1, dequantize_bf16, bfloat4x4, 1, dequantize_bf16, 192, 128>;
|
||||
template [[host_name("kernel_flash_attn_ext_bf16_dk256_dv256")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES_BF, bfloat4x4, 1, dequantize_bf16, bfloat4x4, 1, dequantize_bf16, 256, 256>;
|
||||
template [[host_name("kernel_flash_attn_ext_bf16_dk512_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES_BF, bfloat4x4, 1, dequantize_bf16, bfloat4x4, 1, dequantize_bf16, 512, 512>;
|
||||
template [[host_name("kernel_flash_attn_ext_bf16_dk576_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES_BF, bfloat4x4, 1, dequantize_bf16, bfloat4x4, 1, dequantize_bf16, 576, 512>;
|
||||
#endif
|
||||
|
||||
@@ -9142,6 +9145,7 @@ template [[host_name("kernel_flash_attn_ext_q4_0_dk128_dv128")]] kernel flash_at
|
||||
template [[host_name("kernel_flash_attn_ext_q4_0_dk192_dv192")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q4_0, 2, dequantize_q4_0, block_q4_0, 2, dequantize_q4_0, 192, 192>;
|
||||
template [[host_name("kernel_flash_attn_ext_q4_0_dk192_dv128")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q4_0, 2, dequantize_q4_0, block_q4_0, 2, dequantize_q4_0, 192, 128>;
|
||||
template [[host_name("kernel_flash_attn_ext_q4_0_dk256_dv256")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q4_0, 2, dequantize_q4_0, block_q4_0, 2, dequantize_q4_0, 256, 256>;
|
||||
template [[host_name("kernel_flash_attn_ext_q4_0_dk512_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q4_0, 2, dequantize_q4_0, block_q4_0, 2, dequantize_q4_0, 512, 512>;
|
||||
template [[host_name("kernel_flash_attn_ext_q4_0_dk576_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q4_0, 2, dequantize_q4_0, block_q4_0, 2, dequantize_q4_0, 576, 512>;
|
||||
|
||||
template [[host_name("kernel_flash_attn_ext_q4_1_dk32_dv32" )]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q4_1, 2, dequantize_q4_1, block_q4_1, 2, dequantize_q4_1, 32, 32>;
|
||||
@@ -9156,6 +9160,7 @@ template [[host_name("kernel_flash_attn_ext_q4_1_dk128_dv128")]] kernel flash_at
|
||||
template [[host_name("kernel_flash_attn_ext_q4_1_dk192_dv192")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q4_1, 2, dequantize_q4_1, block_q4_1, 2, dequantize_q4_1, 192, 192>;
|
||||
template [[host_name("kernel_flash_attn_ext_q4_1_dk192_dv128")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q4_1, 2, dequantize_q4_1, block_q4_1, 2, dequantize_q4_1, 192, 128>;
|
||||
template [[host_name("kernel_flash_attn_ext_q4_1_dk256_dv256")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q4_1, 2, dequantize_q4_1, block_q4_1, 2, dequantize_q4_1, 256, 256>;
|
||||
template [[host_name("kernel_flash_attn_ext_q4_1_dk512_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q4_1, 2, dequantize_q4_1, block_q4_1, 2, dequantize_q4_1, 512, 512>;
|
||||
template [[host_name("kernel_flash_attn_ext_q4_1_dk576_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q4_1, 2, dequantize_q4_1, block_q4_1, 2, dequantize_q4_1, 576, 512>;
|
||||
|
||||
template [[host_name("kernel_flash_attn_ext_q5_0_dk32_dv32" )]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_0, 2, dequantize_q5_0, block_q5_0, 2, dequantize_q5_0, 32, 32>;
|
||||
@@ -9170,6 +9175,7 @@ template [[host_name("kernel_flash_attn_ext_q5_0_dk128_dv128")]] kernel flash_at
|
||||
template [[host_name("kernel_flash_attn_ext_q5_0_dk192_dv192")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_0, 2, dequantize_q5_0, block_q5_0, 2, dequantize_q5_0, 192, 192>;
|
||||
template [[host_name("kernel_flash_attn_ext_q5_0_dk192_dv128")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_0, 2, dequantize_q5_0, block_q5_0, 2, dequantize_q5_0, 192, 128>;
|
||||
template [[host_name("kernel_flash_attn_ext_q5_0_dk256_dv256")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_0, 2, dequantize_q5_0, block_q5_0, 2, dequantize_q5_0, 256, 256>;
|
||||
template [[host_name("kernel_flash_attn_ext_q5_0_dk512_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_0, 2, dequantize_q5_0, block_q5_0, 2, dequantize_q5_0, 512, 512>;
|
||||
template [[host_name("kernel_flash_attn_ext_q5_0_dk576_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_0, 2, dequantize_q5_0, block_q5_0, 2, dequantize_q5_0, 576, 512>;
|
||||
|
||||
template [[host_name("kernel_flash_attn_ext_q5_1_dk32_dv32" )]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_1, 2, dequantize_q5_1, block_q5_1, 2, dequantize_q5_1, 32, 32>;
|
||||
@@ -9184,6 +9190,7 @@ template [[host_name("kernel_flash_attn_ext_q5_1_dk128_dv128")]] kernel flash_at
|
||||
template [[host_name("kernel_flash_attn_ext_q5_1_dk192_dv192")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_1, 2, dequantize_q5_1, block_q5_1, 2, dequantize_q5_1, 192, 192>;
|
||||
template [[host_name("kernel_flash_attn_ext_q5_1_dk192_dv128")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_1, 2, dequantize_q5_1, block_q5_1, 2, dequantize_q5_1, 192, 128>;
|
||||
template [[host_name("kernel_flash_attn_ext_q5_1_dk256_dv256")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_1, 2, dequantize_q5_1, block_q5_1, 2, dequantize_q5_1, 256, 256>;
|
||||
template [[host_name("kernel_flash_attn_ext_q5_1_dk512_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_1, 2, dequantize_q5_1, block_q5_1, 2, dequantize_q5_1, 512, 512>;
|
||||
template [[host_name("kernel_flash_attn_ext_q5_1_dk576_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_1, 2, dequantize_q5_1, block_q5_1, 2, dequantize_q5_1, 576, 512>;
|
||||
|
||||
template [[host_name("kernel_flash_attn_ext_q8_0_dk32_dv32" )]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q8_0, 2, dequantize_q8_0, block_q8_0, 2, dequantize_q8_0, 32, 32>;
|
||||
@@ -9198,6 +9205,7 @@ template [[host_name("kernel_flash_attn_ext_q8_0_dk128_dv128")]] kernel flash_at
|
||||
template [[host_name("kernel_flash_attn_ext_q8_0_dk192_dv192")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q8_0, 2, dequantize_q8_0, block_q8_0, 2, dequantize_q8_0, 192, 192>;
|
||||
template [[host_name("kernel_flash_attn_ext_q8_0_dk192_dv128")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q8_0, 2, dequantize_q8_0, block_q8_0, 2, dequantize_q8_0, 192, 128>;
|
||||
template [[host_name("kernel_flash_attn_ext_q8_0_dk256_dv256")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q8_0, 2, dequantize_q8_0, block_q8_0, 2, dequantize_q8_0, 256, 256>;
|
||||
template [[host_name("kernel_flash_attn_ext_q8_0_dk512_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q8_0, 2, dequantize_q8_0, block_q8_0, 2, dequantize_q8_0, 512, 512>;
|
||||
template [[host_name("kernel_flash_attn_ext_q8_0_dk576_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q8_0, 2, dequantize_q8_0, block_q8_0, 2, dequantize_q8_0, 576, 512>;
|
||||
|
||||
#undef FA_TYPES
|
||||
@@ -9812,6 +9820,17 @@ template [[host_name("kernel_flash_attn_ext_vec_q5_0_dk256_dv256")]] kernel flas
|
||||
template [[host_name("kernel_flash_attn_ext_vec_q5_1_dk256_dv256")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES, block_q5_1, 8, dequantize_q5_1_t4, block_q5_1, 8, dequantize_q5_1_t4, 256, 256, 1>;
|
||||
template [[host_name("kernel_flash_attn_ext_vec_q8_0_dk256_dv256")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES, block_q8_0, 8, dequantize_q8_0_t4, block_q8_0, 8, dequantize_q8_0_t4, 256, 256, 1>;
|
||||
|
||||
template [[host_name("kernel_flash_attn_ext_vec_f32_dk512_dv512")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES_F32, float4, 1, dequantize_f32_t4, float4, 1, dequantize_f32_t4, 512, 512, 1>;
|
||||
template [[host_name("kernel_flash_attn_ext_vec_f16_dk512_dv512")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES, half4, 1, dequantize_f16_t4, half4, 1, dequantize_f16_t4, 512, 512, 1>;
|
||||
#if defined(GGML_METAL_HAS_BF16)
|
||||
template [[host_name("kernel_flash_attn_ext_vec_bf16_dk512_dv512")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES, bfloat4, 1, dequantize_bf16_t4, bfloat4, 1, dequantize_bf16_t4, 512, 512, 1>;
|
||||
#endif
|
||||
template [[host_name("kernel_flash_attn_ext_vec_q4_0_dk512_dv512")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES, block_q4_0, 8, dequantize_q4_0_t4, block_q4_0, 8, dequantize_q4_0_t4, 512, 512, 1>;
|
||||
template [[host_name("kernel_flash_attn_ext_vec_q4_1_dk512_dv512")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES, block_q4_1, 8, dequantize_q4_1_t4, block_q4_1, 8, dequantize_q4_1_t4, 512, 512, 1>;
|
||||
template [[host_name("kernel_flash_attn_ext_vec_q5_0_dk512_dv512")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES, block_q5_0, 8, dequantize_q5_0_t4, block_q5_0, 8, dequantize_q5_0_t4, 512, 512, 1>;
|
||||
template [[host_name("kernel_flash_attn_ext_vec_q5_1_dk512_dv512")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES, block_q5_1, 8, dequantize_q5_1_t4, block_q5_1, 8, dequantize_q5_1_t4, 512, 512, 1>;
|
||||
template [[host_name("kernel_flash_attn_ext_vec_q8_0_dk512_dv512")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES, block_q8_0, 8, dequantize_q8_0_t4, block_q8_0, 8, dequantize_q8_0_t4, 512, 512, 1>;
|
||||
|
||||
template [[host_name("kernel_flash_attn_ext_vec_f32_dk576_dv512")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES_F32, float4, 1, dequantize_f32_t4, float4, 1, dequantize_f32_t4, 576, 512, 2>;
|
||||
template [[host_name("kernel_flash_attn_ext_vec_f16_dk576_dv512")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES, half4, 1, dequantize_f16_t4, half4, 1, dequantize_f16_t4, 576, 512, 2>;
|
||||
#if defined(GGML_METAL_HAS_BF16)
|
||||
|
||||
+20
-1
@@ -4300,7 +4300,7 @@ kernel void kernel_rope_multi(
|
||||
const int sec_w012 = args.sect_0 + args.sect_1 + args.sect_2; // end of section 2
|
||||
const int sector = ic % sect_dims;
|
||||
|
||||
float theta_base;
|
||||
float theta_base = 0.0;
|
||||
if (FC_rope_is_imrope) {
|
||||
if (sector % 3 == 1 && sector < 1 + 3 * args.sect_1) { // h
|
||||
theta_base = (float) pos[i2 + args.ne02 * 1];
|
||||
@@ -6276,6 +6276,7 @@ template [[host_name("kernel_flash_attn_ext_f32_dk128_dv128")]] kernel flash_at
|
||||
template [[host_name("kernel_flash_attn_ext_f32_dk192_dv192")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES_F32, float4x4, 1, dequantize_f32, float4x4, 1, dequantize_f32, 192, 192>;
|
||||
template [[host_name("kernel_flash_attn_ext_f32_dk192_dv128")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES_F32, float4x4, 1, dequantize_f32, float4x4, 1, dequantize_f32, 192, 128>;
|
||||
template [[host_name("kernel_flash_attn_ext_f32_dk256_dv256")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES_F32, float4x4, 1, dequantize_f32, float4x4, 1, dequantize_f32, 256, 256>;
|
||||
template [[host_name("kernel_flash_attn_ext_f32_dk512_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES_F32, float4x4, 1, dequantize_f32, float4x4, 1, dequantize_f32, 512, 512>;
|
||||
template [[host_name("kernel_flash_attn_ext_f32_dk576_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES_F32, float4x4, 1, dequantize_f32, float4x4, 1, dequantize_f32, 576, 512>;
|
||||
|
||||
template [[host_name("kernel_flash_attn_ext_f16_dk32_dv32" )]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, half4x4, 1, dequantize_f16, half4x4, 1, dequantize_f16, 32, 32>;
|
||||
@@ -6290,6 +6291,7 @@ template [[host_name("kernel_flash_attn_ext_f16_dk128_dv128")]] kernel flash_at
|
||||
template [[host_name("kernel_flash_attn_ext_f16_dk192_dv192")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, half4x4, 1, dequantize_f16, half4x4, 1, dequantize_f16, 192, 192>;
|
||||
template [[host_name("kernel_flash_attn_ext_f16_dk192_dv128")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, half4x4, 1, dequantize_f16, half4x4, 1, dequantize_f16, 192, 128>;
|
||||
template [[host_name("kernel_flash_attn_ext_f16_dk256_dv256")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, half4x4, 1, dequantize_f16, half4x4, 1, dequantize_f16, 256, 256>;
|
||||
template [[host_name("kernel_flash_attn_ext_f16_dk512_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, half4x4, 1, dequantize_f16, half4x4, 1, dequantize_f16, 512, 512>;
|
||||
template [[host_name("kernel_flash_attn_ext_f16_dk576_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, half4x4, 1, dequantize_f16, half4x4, 1, dequantize_f16, 576, 512>;
|
||||
|
||||
#if defined(GGML_METAL_HAS_BF16)
|
||||
@@ -6305,6 +6307,7 @@ template [[host_name("kernel_flash_attn_ext_bf16_dk128_dv128")]] kernel flash_at
|
||||
template [[host_name("kernel_flash_attn_ext_bf16_dk192_dv192")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES_BF, bfloat4x4, 1, dequantize_bf16, bfloat4x4, 1, dequantize_bf16, 192, 192>;
|
||||
template [[host_name("kernel_flash_attn_ext_bf16_dk192_dv128")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES_BF, bfloat4x4, 1, dequantize_bf16, bfloat4x4, 1, dequantize_bf16, 192, 128>;
|
||||
template [[host_name("kernel_flash_attn_ext_bf16_dk256_dv256")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES_BF, bfloat4x4, 1, dequantize_bf16, bfloat4x4, 1, dequantize_bf16, 256, 256>;
|
||||
template [[host_name("kernel_flash_attn_ext_bf16_dk512_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES_BF, bfloat4x4, 1, dequantize_bf16, bfloat4x4, 1, dequantize_bf16, 512, 512>;
|
||||
template [[host_name("kernel_flash_attn_ext_bf16_dk576_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES_BF, bfloat4x4, 1, dequantize_bf16, bfloat4x4, 1, dequantize_bf16, 576, 512>;
|
||||
#endif
|
||||
|
||||
@@ -6320,6 +6323,7 @@ template [[host_name("kernel_flash_attn_ext_q4_0_dk128_dv128")]] kernel flash_at
|
||||
template [[host_name("kernel_flash_attn_ext_q4_0_dk192_dv192")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q4_0, 2, dequantize_q4_0, block_q4_0, 2, dequantize_q4_0, 192, 192>;
|
||||
template [[host_name("kernel_flash_attn_ext_q4_0_dk192_dv128")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q4_0, 2, dequantize_q4_0, block_q4_0, 2, dequantize_q4_0, 192, 128>;
|
||||
template [[host_name("kernel_flash_attn_ext_q4_0_dk256_dv256")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q4_0, 2, dequantize_q4_0, block_q4_0, 2, dequantize_q4_0, 256, 256>;
|
||||
template [[host_name("kernel_flash_attn_ext_q4_0_dk512_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q4_0, 2, dequantize_q4_0, block_q4_0, 2, dequantize_q4_0, 512, 512>;
|
||||
template [[host_name("kernel_flash_attn_ext_q4_0_dk576_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q4_0, 2, dequantize_q4_0, block_q4_0, 2, dequantize_q4_0, 576, 512>;
|
||||
|
||||
template [[host_name("kernel_flash_attn_ext_q4_1_dk32_dv32" )]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q4_1, 2, dequantize_q4_1, block_q4_1, 2, dequantize_q4_1, 32, 32>;
|
||||
@@ -6334,6 +6338,7 @@ template [[host_name("kernel_flash_attn_ext_q4_1_dk128_dv128")]] kernel flash_at
|
||||
template [[host_name("kernel_flash_attn_ext_q4_1_dk192_dv192")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q4_1, 2, dequantize_q4_1, block_q4_1, 2, dequantize_q4_1, 192, 192>;
|
||||
template [[host_name("kernel_flash_attn_ext_q4_1_dk192_dv128")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q4_1, 2, dequantize_q4_1, block_q4_1, 2, dequantize_q4_1, 192, 128>;
|
||||
template [[host_name("kernel_flash_attn_ext_q4_1_dk256_dv256")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q4_1, 2, dequantize_q4_1, block_q4_1, 2, dequantize_q4_1, 256, 256>;
|
||||
template [[host_name("kernel_flash_attn_ext_q4_1_dk512_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q4_1, 2, dequantize_q4_1, block_q4_1, 2, dequantize_q4_1, 512, 512>;
|
||||
template [[host_name("kernel_flash_attn_ext_q4_1_dk576_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q4_1, 2, dequantize_q4_1, block_q4_1, 2, dequantize_q4_1, 576, 512>;
|
||||
|
||||
template [[host_name("kernel_flash_attn_ext_q5_0_dk32_dv32" )]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_0, 2, dequantize_q5_0, block_q5_0, 2, dequantize_q5_0, 32, 32>;
|
||||
@@ -6348,6 +6353,7 @@ template [[host_name("kernel_flash_attn_ext_q5_0_dk128_dv128")]] kernel flash_at
|
||||
template [[host_name("kernel_flash_attn_ext_q5_0_dk192_dv192")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_0, 2, dequantize_q5_0, block_q5_0, 2, dequantize_q5_0, 192, 192>;
|
||||
template [[host_name("kernel_flash_attn_ext_q5_0_dk192_dv128")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_0, 2, dequantize_q5_0, block_q5_0, 2, dequantize_q5_0, 192, 128>;
|
||||
template [[host_name("kernel_flash_attn_ext_q5_0_dk256_dv256")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_0, 2, dequantize_q5_0, block_q5_0, 2, dequantize_q5_0, 256, 256>;
|
||||
template [[host_name("kernel_flash_attn_ext_q5_0_dk512_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_0, 2, dequantize_q5_0, block_q5_0, 2, dequantize_q5_0, 512, 512>;
|
||||
template [[host_name("kernel_flash_attn_ext_q5_0_dk576_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_0, 2, dequantize_q5_0, block_q5_0, 2, dequantize_q5_0, 576, 512>;
|
||||
|
||||
template [[host_name("kernel_flash_attn_ext_q5_1_dk32_dv32" )]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_1, 2, dequantize_q5_1, block_q5_1, 2, dequantize_q5_1, 32, 32>;
|
||||
@@ -6362,6 +6368,7 @@ template [[host_name("kernel_flash_attn_ext_q5_1_dk128_dv128")]] kernel flash_at
|
||||
template [[host_name("kernel_flash_attn_ext_q5_1_dk192_dv192")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_1, 2, dequantize_q5_1, block_q5_1, 2, dequantize_q5_1, 192, 192>;
|
||||
template [[host_name("kernel_flash_attn_ext_q5_1_dk192_dv128")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_1, 2, dequantize_q5_1, block_q5_1, 2, dequantize_q5_1, 192, 128>;
|
||||
template [[host_name("kernel_flash_attn_ext_q5_1_dk256_dv256")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_1, 2, dequantize_q5_1, block_q5_1, 2, dequantize_q5_1, 256, 256>;
|
||||
template [[host_name("kernel_flash_attn_ext_q5_1_dk512_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_1, 2, dequantize_q5_1, block_q5_1, 2, dequantize_q5_1, 512, 512>;
|
||||
template [[host_name("kernel_flash_attn_ext_q5_1_dk576_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q5_1, 2, dequantize_q5_1, block_q5_1, 2, dequantize_q5_1, 576, 512>;
|
||||
|
||||
template [[host_name("kernel_flash_attn_ext_q8_0_dk32_dv32" )]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q8_0, 2, dequantize_q8_0, block_q8_0, 2, dequantize_q8_0, 32, 32>;
|
||||
@@ -6376,6 +6383,7 @@ template [[host_name("kernel_flash_attn_ext_q8_0_dk128_dv128")]] kernel flash_at
|
||||
template [[host_name("kernel_flash_attn_ext_q8_0_dk192_dv192")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q8_0, 2, dequantize_q8_0, block_q8_0, 2, dequantize_q8_0, 192, 192>;
|
||||
template [[host_name("kernel_flash_attn_ext_q8_0_dk192_dv128")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q8_0, 2, dequantize_q8_0, block_q8_0, 2, dequantize_q8_0, 192, 128>;
|
||||
template [[host_name("kernel_flash_attn_ext_q8_0_dk256_dv256")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q8_0, 2, dequantize_q8_0, block_q8_0, 2, dequantize_q8_0, 256, 256>;
|
||||
template [[host_name("kernel_flash_attn_ext_q8_0_dk512_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q8_0, 2, dequantize_q8_0, block_q8_0, 2, dequantize_q8_0, 512, 512>;
|
||||
template [[host_name("kernel_flash_attn_ext_q8_0_dk576_dv512")]] kernel flash_attn_ext_t kernel_flash_attn_ext<FA_TYPES, block_q8_0, 2, dequantize_q8_0, block_q8_0, 2, dequantize_q8_0, 576, 512>;
|
||||
|
||||
#undef FA_TYPES
|
||||
@@ -6990,6 +6998,17 @@ template [[host_name("kernel_flash_attn_ext_vec_q5_0_dk256_dv256")]] kernel flas
|
||||
template [[host_name("kernel_flash_attn_ext_vec_q5_1_dk256_dv256")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES, block_q5_1, 8, dequantize_q5_1_t4, block_q5_1, 8, dequantize_q5_1_t4, 256, 256, 1>;
|
||||
template [[host_name("kernel_flash_attn_ext_vec_q8_0_dk256_dv256")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES, block_q8_0, 8, dequantize_q8_0_t4, block_q8_0, 8, dequantize_q8_0_t4, 256, 256, 1>;
|
||||
|
||||
template [[host_name("kernel_flash_attn_ext_vec_f32_dk512_dv512")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES_F32, float4, 1, dequantize_f32_t4, float4, 1, dequantize_f32_t4, 512, 512, 1>;
|
||||
template [[host_name("kernel_flash_attn_ext_vec_f16_dk512_dv512")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES, half4, 1, dequantize_f16_t4, half4, 1, dequantize_f16_t4, 512, 512, 1>;
|
||||
#if defined(GGML_METAL_HAS_BF16)
|
||||
template [[host_name("kernel_flash_attn_ext_vec_bf16_dk512_dv512")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES, bfloat4, 1, dequantize_bf16_t4, bfloat4, 1, dequantize_bf16_t4, 512, 512, 1>;
|
||||
#endif
|
||||
template [[host_name("kernel_flash_attn_ext_vec_q4_0_dk512_dv512")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES, block_q4_0, 8, dequantize_q4_0_t4, block_q4_0, 8, dequantize_q4_0_t4, 512, 512, 1>;
|
||||
template [[host_name("kernel_flash_attn_ext_vec_q4_1_dk512_dv512")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES, block_q4_1, 8, dequantize_q4_1_t4, block_q4_1, 8, dequantize_q4_1_t4, 512, 512, 1>;
|
||||
template [[host_name("kernel_flash_attn_ext_vec_q5_0_dk512_dv512")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES, block_q5_0, 8, dequantize_q5_0_t4, block_q5_0, 8, dequantize_q5_0_t4, 512, 512, 1>;
|
||||
template [[host_name("kernel_flash_attn_ext_vec_q5_1_dk512_dv512")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES, block_q5_1, 8, dequantize_q5_1_t4, block_q5_1, 8, dequantize_q5_1_t4, 512, 512, 1>;
|
||||
template [[host_name("kernel_flash_attn_ext_vec_q8_0_dk512_dv512")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES, block_q8_0, 8, dequantize_q8_0_t4, block_q8_0, 8, dequantize_q8_0_t4, 512, 512, 1>;
|
||||
|
||||
template [[host_name("kernel_flash_attn_ext_vec_f32_dk576_dv512")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES_F32, float4, 1, dequantize_f32_t4, float4, 1, dequantize_f32_t4, 576, 512, 2>;
|
||||
template [[host_name("kernel_flash_attn_ext_vec_f16_dk576_dv512")]] kernel flash_attn_ext_vec_t kernel_flash_attn_ext_vec<FA_TYPES, half4, 1, dequantize_f16_t4, half4, 1, dequantize_f16_t4, 576, 512, 2>;
|
||||
#if defined(GGML_METAL_HAS_BF16)
|
||||
|
||||
@@ -33,8 +33,9 @@ const (
|
||||
)
|
||||
|
||||
type CogitoParser struct {
|
||||
state CogitoParserState
|
||||
buffer strings.Builder
|
||||
state CogitoParserState
|
||||
buffer strings.Builder
|
||||
callIndex int
|
||||
}
|
||||
|
||||
func (p *CogitoParser) HasToolSupport() bool {
|
||||
@@ -72,6 +73,7 @@ func (p *CogitoParser) setInitialState(lastMessage *api.Message, tools []api.Too
|
||||
}
|
||||
|
||||
func (p *CogitoParser) Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool {
|
||||
p.callIndex = 0
|
||||
p.setInitialState(lastMessage, tools, thinkValue)
|
||||
return tools
|
||||
}
|
||||
@@ -114,6 +116,11 @@ func (p *CogitoParser) Add(s string, done bool) (content string, thinking string
|
||||
}
|
||||
}
|
||||
|
||||
for i := range toolCalls {
|
||||
toolCalls[i].Function.Index = p.callIndex
|
||||
p.callIndex++
|
||||
}
|
||||
|
||||
return contentSb.String(), thinkingSb.String(), toolCalls, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,8 @@ func TestCogitoParser(t *testing.T) {
|
||||
expectedToolCalls: []api.ToolCall{
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "get_weather",
|
||||
Index: 0,
|
||||
Name: "get_weather",
|
||||
Arguments: testArgs(map[string]any{
|
||||
"location": "Paris",
|
||||
}),
|
||||
@@ -110,7 +111,8 @@ func TestCogitoParser(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "get_weather",
|
||||
Index: 1,
|
||||
Name: "get_weather",
|
||||
Arguments: testArgs(map[string]any{
|
||||
"location": "London",
|
||||
}),
|
||||
|
||||
@@ -33,6 +33,7 @@ const (
|
||||
type DeepSeek3Parser struct {
|
||||
state DeepSeek3ParserState
|
||||
buffer strings.Builder
|
||||
callIndex int
|
||||
hasThinkingSupport bool
|
||||
}
|
||||
|
||||
@@ -64,6 +65,7 @@ func (p *DeepSeek3Parser) setInitialState(lastMessage *api.Message, tools []api.
|
||||
}
|
||||
|
||||
func (p *DeepSeek3Parser) Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool {
|
||||
p.callIndex = 0
|
||||
p.setInitialState(lastMessage, tools, thinkValue)
|
||||
return tools
|
||||
}
|
||||
@@ -106,6 +108,11 @@ func (p *DeepSeek3Parser) Add(s string, done bool) (content string, thinking str
|
||||
}
|
||||
}
|
||||
|
||||
for i := range toolCalls {
|
||||
toolCalls[i].Function.Index = p.callIndex
|
||||
p.callIndex++
|
||||
}
|
||||
|
||||
return contentSb.String(), thinkingSb.String(), toolCalls, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,8 @@ func TestDeepSeekParser(t *testing.T) {
|
||||
expectedCalls: []api.ToolCall{
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "get_weather",
|
||||
Index: 0,
|
||||
Name: "get_weather",
|
||||
Arguments: testArgs(map[string]any{
|
||||
"location": "Paris",
|
||||
}),
|
||||
@@ -74,7 +75,8 @@ func TestDeepSeekParser(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "get_weather",
|
||||
Index: 1,
|
||||
Name: "get_weather",
|
||||
Arguments: testArgs(map[string]any{
|
||||
"location": "London",
|
||||
}),
|
||||
|
||||
@@ -22,9 +22,10 @@ const (
|
||||
|
||||
// This format uses <start_function_call>call:name{args}<end_function_call> for tool calls.
|
||||
type FunctionGemmaParser struct {
|
||||
state FunctionGemmaParserState
|
||||
buffer strings.Builder
|
||||
tools []api.Tool
|
||||
state FunctionGemmaParserState
|
||||
buffer strings.Builder
|
||||
tools []api.Tool
|
||||
callIndex int
|
||||
}
|
||||
|
||||
func (p *FunctionGemmaParser) HasToolSupport() bool { return true }
|
||||
@@ -33,6 +34,7 @@ func (p *FunctionGemmaParser) HasThinkingSupport() bool { return false }
|
||||
func (p *FunctionGemmaParser) Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool {
|
||||
p.tools = tools
|
||||
p.state = FunctionGemmaCollectingContent
|
||||
p.callIndex = 0
|
||||
return tools
|
||||
}
|
||||
|
||||
@@ -66,6 +68,11 @@ func (p *FunctionGemmaParser) Add(s string, done bool) (content string, thinking
|
||||
}
|
||||
}
|
||||
|
||||
for i := range toolCalls {
|
||||
toolCalls[i].Function.Index = p.callIndex
|
||||
p.callIndex++
|
||||
}
|
||||
|
||||
return contentSb.String(), "", toolCalls, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -124,12 +124,14 @@ func TestFunctionGemmaParser(t *testing.T) {
|
||||
expectedCalls: []api.ToolCall{
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Index: 0,
|
||||
Name: "get_weather",
|
||||
Arguments: testArgs(map[string]any{"city": "Paris"}),
|
||||
},
|
||||
},
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Index: 1,
|
||||
Name: "get_weather",
|
||||
Arguments: testArgs(map[string]any{"city": "London"}),
|
||||
},
|
||||
@@ -345,12 +347,14 @@ func TestFunctionGemmaParser(t *testing.T) {
|
||||
expectedCalls: []api.ToolCall{
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Index: 0,
|
||||
Name: "get_weather",
|
||||
Arguments: testArgs(map[string]any{"city": "Paris"}),
|
||||
},
|
||||
},
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Index: 1,
|
||||
Name: "get_time",
|
||||
Arguments: testArgs(map[string]any{"timezone": "UTC"}),
|
||||
},
|
||||
@@ -372,12 +376,14 @@ func TestFunctionGemmaParser(t *testing.T) {
|
||||
expectedCalls: []api.ToolCall{
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Index: 0,
|
||||
Name: "first",
|
||||
Arguments: api.NewToolCallFunctionArguments(),
|
||||
},
|
||||
},
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Index: 1,
|
||||
Name: "second",
|
||||
Arguments: api.NewToolCallFunctionArguments(),
|
||||
},
|
||||
|
||||
+489
-60
@@ -4,8 +4,10 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
@@ -16,6 +18,7 @@ const (
|
||||
Gemma4CollectingContent Gemma4ParserState = iota
|
||||
Gemma4CollectingThinking
|
||||
Gemma4CollectingToolCall
|
||||
Gemma4IgnoringPostToolCallNoise
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -23,11 +26,19 @@ const (
|
||||
gemma4ThinkingCloseTag = "<channel|>"
|
||||
gemma4ToolCallOpenTag = "<|tool_call>"
|
||||
gemma4ToolCallCloseTag = "<tool_call|>"
|
||||
gemma4ToolResponseTag = "<|tool_response>"
|
||||
gemma4StringDelimiter = `<|"|>`
|
||||
)
|
||||
|
||||
var (
|
||||
gemma4QuotedStringRe = regexp.MustCompile(`(?s)<\|"\|>(.*?)<\|"\|>`)
|
||||
)
|
||||
|
||||
type Gemma4Parser struct {
|
||||
state Gemma4ParserState
|
||||
buffer strings.Builder
|
||||
tools []api.Tool
|
||||
callIndex int
|
||||
hasThinkingSupport bool
|
||||
thinkingEnabled bool // true when both model supports and user requested thinking
|
||||
needsChannelNameStrip bool // true when we just entered thinking and need to strip "thought\n"
|
||||
@@ -42,6 +53,9 @@ func (p *Gemma4Parser) HasThinkingSupport() bool {
|
||||
}
|
||||
|
||||
func (p *Gemma4Parser) Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool {
|
||||
p.tools = tools
|
||||
p.callIndex = 0
|
||||
|
||||
prefill := lastMessage != nil && lastMessage.Role == "assistant"
|
||||
|
||||
p.thinkingEnabled = p.HasThinkingSupport() && (thinkValue != nil && thinkValue.Bool())
|
||||
@@ -104,6 +118,11 @@ func (p *Gemma4Parser) Add(s string, done bool) (content string, thinking string
|
||||
}
|
||||
}
|
||||
|
||||
for i := range toolCalls {
|
||||
toolCalls[i].Function.Index = p.callIndex
|
||||
p.callIndex++
|
||||
}
|
||||
|
||||
return contentSb.String(), thinkingSb.String(), toolCalls, nil
|
||||
}
|
||||
|
||||
@@ -279,9 +298,9 @@ func (p *Gemma4Parser) eat(done bool) ([]gemma4Event, bool) {
|
||||
|
||||
p.buffer.Reset()
|
||||
p.buffer.WriteString(remaining)
|
||||
p.state = Gemma4CollectingContent
|
||||
p.state = Gemma4IgnoringPostToolCallNoise
|
||||
|
||||
if toolCall, err := parseGemma4ToolCall(toolCallContent); err == nil {
|
||||
if toolCall, err := parseGemma4ToolCall(toolCallContent, p.tools); err == nil {
|
||||
events = append(events, gemma4EventToolCall{toolCall: toolCall})
|
||||
} else {
|
||||
slog.Warn("gemma4 tool call parsing failed", "error", err, "content", toolCallContent)
|
||||
@@ -294,7 +313,7 @@ func (p *Gemma4Parser) eat(done bool) ([]gemma4Event, bool) {
|
||||
if done && len(bufStr) > 0 {
|
||||
p.buffer.Reset()
|
||||
p.state = Gemma4CollectingContent
|
||||
if toolCall, err := parseGemma4ToolCall(bufStr); err == nil {
|
||||
if toolCall, err := parseGemma4ToolCall(bufStr, p.tools); err == nil {
|
||||
events = append(events, gemma4EventToolCall{toolCall: toolCall})
|
||||
} else {
|
||||
slog.Warn("gemma4 tool call flush on done failed", "error", err, "content", bufStr)
|
||||
@@ -304,6 +323,51 @@ func (p *Gemma4Parser) eat(done bool) ([]gemma4Event, bool) {
|
||||
|
||||
// Wait for closing tag
|
||||
return events, false
|
||||
|
||||
case Gemma4IgnoringPostToolCallNoise:
|
||||
// We've observed Gemma 4 occasionally emitting extra <tool_call|> tags
|
||||
// after a valid tool call. We suppress those leading control tags in this
|
||||
// immediate post-tool-call state so they do not leak into assistant
|
||||
// content. The tradeoff is that if the model intentionally begins its next
|
||||
// content span with one of those literal strings, we will erroneously
|
||||
// treat it as noise and drop it. We also suppress a leading
|
||||
// <|tool_response> marker here because the updated upstream parser/template
|
||||
// uses it as a post-tool-call boundary.
|
||||
bufStr = strings.TrimLeftFunc(bufStr, unicode.IsSpace)
|
||||
p.buffer.Reset()
|
||||
p.buffer.WriteString(bufStr)
|
||||
|
||||
for {
|
||||
switch {
|
||||
case strings.HasPrefix(bufStr, gemma4ToolCallCloseTag):
|
||||
bufStr = strings.TrimLeftFunc(bufStr[len(gemma4ToolCallCloseTag):], unicode.IsSpace)
|
||||
case strings.HasPrefix(bufStr, gemma4ToolResponseTag):
|
||||
bufStr = strings.TrimLeftFunc(bufStr[len(gemma4ToolResponseTag):], unicode.IsSpace)
|
||||
default:
|
||||
p.buffer.Reset()
|
||||
p.buffer.WriteString(bufStr)
|
||||
goto strippedPostToolCallNoise
|
||||
}
|
||||
|
||||
p.buffer.Reset()
|
||||
p.buffer.WriteString(bufStr)
|
||||
}
|
||||
|
||||
strippedPostToolCallNoise:
|
||||
if bufStr == "" {
|
||||
return events, false
|
||||
}
|
||||
|
||||
if strings.HasPrefix(gemma4ToolCallCloseTag, bufStr) || strings.HasPrefix(gemma4ToolResponseTag, bufStr) {
|
||||
if done {
|
||||
p.buffer.Reset()
|
||||
p.state = Gemma4CollectingContent
|
||||
}
|
||||
return events, false
|
||||
}
|
||||
|
||||
p.state = Gemma4CollectingContent
|
||||
return events, true
|
||||
}
|
||||
|
||||
return events, false
|
||||
@@ -311,7 +375,7 @@ func (p *Gemma4Parser) eat(done bool) ([]gemma4Event, bool) {
|
||||
|
||||
// parseGemma4ToolCall parses a tool call in Gemma 4 format:
|
||||
// call:NAME{key:value,key:value}
|
||||
func parseGemma4ToolCall(content string) (api.ToolCall, error) {
|
||||
func parseGemma4ToolCall(content string, tools []api.Tool) (api.ToolCall, error) {
|
||||
// Expected format: call:NAME{args}
|
||||
if !strings.HasPrefix(content, "call:") {
|
||||
return api.ToolCall{}, errors.New("expected 'call:' prefix")
|
||||
@@ -332,7 +396,11 @@ func parseGemma4ToolCall(content string) (api.ToolCall, error) {
|
||||
|
||||
var args api.ToolCallFunctionArguments
|
||||
if err := json.Unmarshal([]byte(jsonStr), &args); err != nil {
|
||||
return api.ToolCall{}, err
|
||||
repairedArgs, repairErr := repairGemma4ToolCallArgs(argsStr, toolName, tools)
|
||||
if repairErr != nil {
|
||||
return api.ToolCall{}, errors.Join(err, repairErr)
|
||||
}
|
||||
args = repairedArgs
|
||||
}
|
||||
|
||||
return api.ToolCall{
|
||||
@@ -345,68 +413,429 @@ func parseGemma4ToolCall(content string) (api.ToolCall, error) {
|
||||
|
||||
// gemma4ArgsToJSON converts Gemma 4's custom argument format to valid JSON.
|
||||
func gemma4ArgsToJSON(s string) string {
|
||||
s = strings.ReplaceAll(s, `<|"|>`, `"`)
|
||||
var quotedStrings []string
|
||||
text := gemma4QuotedStringRe.ReplaceAllStringFunc(s, func(match string) string {
|
||||
submatches := gemma4QuotedStringRe.FindStringSubmatch(match)
|
||||
quotedStrings = append(quotedStrings, submatches[1])
|
||||
return "\x00" + string(rune(len(quotedStrings)-1)) + "\x00"
|
||||
})
|
||||
|
||||
var buf strings.Builder
|
||||
buf.Grow(len(s) + 32)
|
||||
inString := false
|
||||
hex := "0123456789abcdef"
|
||||
i := 0
|
||||
for i < len(s) {
|
||||
ch := s[i]
|
||||
text = quoteGemma4BareKeys(text)
|
||||
|
||||
if ch == '"' {
|
||||
inString = !inString
|
||||
buf.WriteByte('"')
|
||||
for i, value := range quotedStrings {
|
||||
escaped, _ := json.Marshal(value)
|
||||
text = strings.ReplaceAll(text, "\x00"+string(rune(i))+"\x00", string(escaped))
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
func quoteGemma4BareKeys(s string) string {
|
||||
var sb strings.Builder
|
||||
sb.Grow(len(s) + 16)
|
||||
|
||||
for i := 0; i < len(s); {
|
||||
if s[i] == '"' {
|
||||
if end := gemma4JSONQuotedStringEnd(s, i); end != -1 {
|
||||
sb.WriteString(s[i:end])
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if s[i] != '{' && s[i] != ',' {
|
||||
sb.WriteByte(s[i])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if inString {
|
||||
switch ch {
|
||||
case '\\':
|
||||
buf.WriteString(`\\`)
|
||||
case '\n':
|
||||
buf.WriteString(`\n`)
|
||||
case '\r':
|
||||
buf.WriteString(`\r`)
|
||||
case '\t':
|
||||
buf.WriteString(`\t`)
|
||||
case '\b':
|
||||
buf.WriteString(`\b`)
|
||||
case '\f':
|
||||
buf.WriteString(`\f`)
|
||||
default:
|
||||
if ch < 0x20 {
|
||||
buf.WriteString(`\u00`)
|
||||
buf.WriteByte(hex[ch>>4])
|
||||
buf.WriteByte(hex[ch&0x0f])
|
||||
} else {
|
||||
buf.WriteByte(ch)
|
||||
}
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
sb.WriteByte(s[i])
|
||||
i++
|
||||
|
||||
if !inString && isIdentStart(ch) {
|
||||
j := i + 1
|
||||
for j < len(s) && isIdentPart(s[j]) {
|
||||
j++
|
||||
}
|
||||
word := s[i:j]
|
||||
if j < len(s) && s[j] == ':' {
|
||||
buf.WriteByte('"')
|
||||
buf.WriteString(word)
|
||||
buf.WriteByte('"')
|
||||
} else {
|
||||
buf.WriteString(word)
|
||||
}
|
||||
i = j
|
||||
} else {
|
||||
buf.WriteByte(ch)
|
||||
i++
|
||||
spaceStart := i
|
||||
i = gemma4SkipSpace(s, i)
|
||||
sb.WriteString(s[spaceStart:i])
|
||||
|
||||
keyEnd := gemma4BareKeyEnd(s, i)
|
||||
if keyEnd > i && keyEnd < len(s) && s[keyEnd] == ':' {
|
||||
sb.WriteByte('"')
|
||||
sb.WriteString(s[i:keyEnd])
|
||||
sb.WriteByte('"')
|
||||
sb.WriteByte(':')
|
||||
i = keyEnd + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
return buf.String()
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func gemma4BareKeyEnd(s string, start int) int {
|
||||
i := start
|
||||
for i < len(s) {
|
||||
r, size := utf8.DecodeRuneInString(s[i:])
|
||||
if !(r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r)) {
|
||||
break
|
||||
}
|
||||
i += size
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// repairGemma4ToolCallArgs is a best-effort repair after strict parsing fails.
|
||||
// For example, if the model emits an unclosed gemma string as the last value,
|
||||
// we can repair it by closing it with the gemma string delimiter.
|
||||
func repairGemma4ToolCallArgs(argsStr, toolName string, tools []api.Tool) (api.ToolCallFunctionArguments, error) {
|
||||
for _, candidate := range gemma4RepairCandidates(argsStr, toolName, tools) {
|
||||
jsonStr := gemma4ArgsToJSON(candidate)
|
||||
|
||||
var args api.ToolCallFunctionArguments
|
||||
if err := json.Unmarshal([]byte(jsonStr), &args); err == nil {
|
||||
return args, nil
|
||||
}
|
||||
}
|
||||
|
||||
return api.ToolCallFunctionArguments{}, errors.New("repair failed to produce valid JSON arguments")
|
||||
}
|
||||
|
||||
func gemma4ToolProperties(toolName string, tools []api.Tool) *api.ToolPropertiesMap {
|
||||
for i := range tools {
|
||||
if tools[i].Function.Name == toolName {
|
||||
return tools[i].Function.Parameters.Properties
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// gemma4RepairCandidates returns the small set of repaired argument strings we
|
||||
// are willing to try after strict parsing fails. Each candidate still has to
|
||||
// pass the normal Gemma4-to-JSON conversion and JSON unmarshal before it is used.
|
||||
func gemma4RepairCandidates(argsStr, toolName string, tools []api.Tool) []string {
|
||||
seen := map[string]bool{}
|
||||
var candidates []string
|
||||
addCandidate := func(candidate string, allowMissingObjectClose bool) {
|
||||
original := candidate
|
||||
candidate = repairGemma4SingleQuotedValues(candidate)
|
||||
candidate = repairGemma4MissingStringDelimiter(candidate)
|
||||
if allowMissingObjectClose || candidate != original {
|
||||
candidate = repairGemma4MissingObjectClose(candidate)
|
||||
}
|
||||
if !seen[candidate] {
|
||||
candidates = append(candidates, candidate)
|
||||
seen[candidate] = true
|
||||
}
|
||||
}
|
||||
|
||||
addCandidate(argsStr, false)
|
||||
if raw, ok := repairGemma4RawTerminalStringValue(argsStr, toolName, tools); ok {
|
||||
addCandidate(raw, true)
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
// repairGemma4MissingStringDelimiter closes an unbalanced Gemma string marker.
|
||||
// When the value is immediately followed by a closing brace/bracket, the marker
|
||||
// is inserted before that structural close rather than after it.
|
||||
func repairGemma4MissingStringDelimiter(s string) string {
|
||||
if strings.Count(s, gemma4StringDelimiter)%2 == 0 {
|
||||
return s
|
||||
}
|
||||
|
||||
insertAt := gemma4TrimRightSpaceIndex(s)
|
||||
if insertAt > 0 && (s[insertAt-1] == '}' || s[insertAt-1] == ']') {
|
||||
insertAt--
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.Grow(len(s) + len(gemma4StringDelimiter))
|
||||
sb.WriteString(s[:insertAt])
|
||||
sb.WriteString(gemma4StringDelimiter)
|
||||
sb.WriteString(s[insertAt:])
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// repairGemma4MissingObjectClose adds a final object close after another repair
|
||||
// has made a truncated object plausible. Callers decide when that guardrail is
|
||||
// satisfied; this helper only performs the mechanical insertion.
|
||||
func repairGemma4MissingObjectClose(s string) string {
|
||||
trimmedStart := strings.TrimLeftFunc(s, unicode.IsSpace)
|
||||
if !strings.HasPrefix(trimmedStart, "{") {
|
||||
return s
|
||||
}
|
||||
|
||||
trimmedEnd := gemma4TrimRightSpaceIndex(s)
|
||||
if trimmedEnd > 0 && s[trimmedEnd-1] == '}' {
|
||||
return s
|
||||
}
|
||||
|
||||
return s[:trimmedEnd] + "}" + s[trimmedEnd:]
|
||||
}
|
||||
|
||||
// repairGemma4SingleQuotedValues converts single-quoted argument values into
|
||||
// Gemma string-delimited values. It also drops a stray Gemma delimiter that
|
||||
// sometimes appears immediately after the closing single quote.
|
||||
func repairGemma4SingleQuotedValues(s string) string {
|
||||
var sb strings.Builder
|
||||
sb.Grow(len(s))
|
||||
|
||||
for i := 0; i < len(s); {
|
||||
if strings.HasPrefix(s[i:], gemma4StringDelimiter) {
|
||||
end := strings.Index(s[i+len(gemma4StringDelimiter):], gemma4StringDelimiter)
|
||||
if end == -1 {
|
||||
sb.WriteString(s[i:])
|
||||
break
|
||||
}
|
||||
|
||||
end = i + len(gemma4StringDelimiter) + end + len(gemma4StringDelimiter)
|
||||
sb.WriteString(s[i:end])
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
|
||||
if s[i] == '"' {
|
||||
end := gemma4JSONQuotedStringEnd(s, i)
|
||||
if end != -1 {
|
||||
sb.WriteString(s[i:end])
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if s[i] != ':' {
|
||||
sb.WriteByte(s[i])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
sb.WriteByte(s[i])
|
||||
i++
|
||||
|
||||
spaceEnd := gemma4SkipSpace(s, i)
|
||||
sb.WriteString(s[i:spaceEnd])
|
||||
i = spaceEnd
|
||||
if i >= len(s) || s[i] != '\'' {
|
||||
continue
|
||||
}
|
||||
|
||||
value, end, ok := gemma4SingleQuotedValue(s, i)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
sb.WriteString(gemma4StringDelimiter)
|
||||
sb.WriteString(value)
|
||||
sb.WriteString(gemma4StringDelimiter)
|
||||
i = end
|
||||
if strings.HasPrefix(s[i:], gemma4StringDelimiter) {
|
||||
i += len(gemma4StringDelimiter)
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func gemma4SingleQuotedValue(s string, start int) (string, int, bool) {
|
||||
var sb strings.Builder
|
||||
escaped := false
|
||||
for i := start + 1; i < len(s); i++ {
|
||||
if s[i] == '\'' && !escaped {
|
||||
return sb.String(), i + 1, true
|
||||
}
|
||||
|
||||
sb.WriteByte(s[i])
|
||||
escaped = s[i] == '\\' && !escaped
|
||||
if s[i] != '\\' {
|
||||
escaped = false
|
||||
}
|
||||
}
|
||||
|
||||
return "", start, false
|
||||
}
|
||||
|
||||
// repairGemma4RawTerminalStringValue wraps a raw value in Gemma string
|
||||
// delimiters only when the tool schema says that argument is a string. This is
|
||||
// deliberately schema-gated because raw text is otherwise too ambiguous.
|
||||
func repairGemma4RawTerminalStringValue(argsStr, toolName string, tools []api.Tool) (string, bool) {
|
||||
props := gemma4ToolProperties(toolName, tools)
|
||||
if props == nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
for key, prop := range props.All() {
|
||||
if !gemma4PropertyAcceptsString(prop) {
|
||||
continue
|
||||
}
|
||||
|
||||
if repaired, ok := repairGemma4RawTerminalStringValueForKey(argsStr, key, props); ok {
|
||||
return repaired, true
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
func repairGemma4RawTerminalStringValueForKey(s, key string, props *api.ToolPropertiesMap) (string, bool) {
|
||||
for searchStart := 0; searchStart < len(s); {
|
||||
valueStart, ok := gemma4FindValueStartForKey(s, key, searchStart)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
valueCheck := gemma4SkipSpace(s, valueStart)
|
||||
if valueCheck < len(s) && gemma4ValueStartsStructured(s, valueCheck) {
|
||||
searchStart = valueStart
|
||||
continue
|
||||
}
|
||||
|
||||
valueEnd := gemma4RawStringValueEnd(s, valueStart, props)
|
||||
return s[:valueStart] + gemma4StringDelimiter + s[valueStart:valueEnd] + gemma4StringDelimiter + s[valueEnd:], true
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
func gemma4FindValueStartForKey(s, key string, searchStart int) (int, bool) {
|
||||
for i := searchStart; i < len(s); i++ {
|
||||
if strings.HasPrefix(s[i:], gemma4StringDelimiter) {
|
||||
end := strings.Index(s[i+len(gemma4StringDelimiter):], gemma4StringDelimiter)
|
||||
if end == -1 {
|
||||
return 0, false
|
||||
}
|
||||
i += len(gemma4StringDelimiter) + end + len(gemma4StringDelimiter) - 1
|
||||
continue
|
||||
}
|
||||
|
||||
if s[i] == '"' {
|
||||
if end := gemma4JSONQuotedStringEnd(s, i); end != -1 {
|
||||
i = end - 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if s[i] != '{' && s[i] != ',' {
|
||||
continue
|
||||
}
|
||||
|
||||
keyStart := gemma4SkipSpace(s, i+1)
|
||||
if !strings.HasPrefix(s[keyStart:], key) {
|
||||
continue
|
||||
}
|
||||
|
||||
colon := gemma4SkipSpace(s, keyStart+len(key))
|
||||
if colon < len(s) && s[colon] == ':' {
|
||||
return colon + 1, true
|
||||
}
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func gemma4RawStringValueEnd(s string, start int, props *api.ToolPropertiesMap) int {
|
||||
for i := start; i < len(s); i++ {
|
||||
if s[i] != ',' {
|
||||
continue
|
||||
}
|
||||
|
||||
keyStart := gemma4SkipSpace(s, i+1)
|
||||
keyEnd := keyStart
|
||||
for keyEnd < len(s) {
|
||||
r, size := utf8.DecodeRuneInString(s[keyEnd:])
|
||||
if !(r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r)) {
|
||||
break
|
||||
}
|
||||
keyEnd += size
|
||||
}
|
||||
if keyEnd == keyStart {
|
||||
continue
|
||||
}
|
||||
|
||||
colon := gemma4SkipSpace(s, keyEnd)
|
||||
if colon < len(s) && s[colon] == ':' {
|
||||
if _, ok := props.Get(s[keyStart:keyEnd]); ok {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
end := gemma4TrimRightSpaceIndex(s)
|
||||
if end > start && s[end-1] == '}' {
|
||||
return end - 1
|
||||
}
|
||||
return len(s)
|
||||
}
|
||||
|
||||
func gemma4ValueStartsStructured(s string, pos int) bool {
|
||||
if pos >= len(s) {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(s[pos:], gemma4StringDelimiter) {
|
||||
return true
|
||||
}
|
||||
|
||||
switch s[pos] {
|
||||
case '\'', '"', '{', '[':
|
||||
return true
|
||||
}
|
||||
|
||||
return gemma4LooksLikeJSONLiteralStart(s[pos])
|
||||
}
|
||||
|
||||
func gemma4JSONQuotedStringEnd(s string, start int) int {
|
||||
escaped := false
|
||||
for i := start + 1; i < len(s); i++ {
|
||||
if s[i] == '"' && !escaped {
|
||||
return i + 1
|
||||
}
|
||||
|
||||
escaped = s[i] == '\\' && !escaped
|
||||
if s[i] != '\\' {
|
||||
escaped = false
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
func gemma4SkipSpace(s string, i int) int {
|
||||
for i < len(s) {
|
||||
r, size := utf8.DecodeRuneInString(s[i:])
|
||||
if !unicode.IsSpace(r) {
|
||||
return i
|
||||
}
|
||||
i += size
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
func gemma4TrimRightSpaceIndex(s string) int {
|
||||
i := len(s)
|
||||
for i > 0 {
|
||||
r, size := utf8.DecodeLastRuneInString(s[:i])
|
||||
if !unicode.IsSpace(r) {
|
||||
return i
|
||||
}
|
||||
i -= size
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
func gemma4PropertyAcceptsString(prop api.ToolProperty) bool {
|
||||
for _, typ := range prop.Type {
|
||||
if strings.EqualFold(typ, "string") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
for _, anyOf := range prop.AnyOf {
|
||||
if gemma4PropertyAcceptsString(anyOf) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func gemma4LooksLikeJSONLiteralStart(ch byte) bool {
|
||||
return ch == '-' || ('0' <= ch && ch <= '9') || ch == 't' || ch == 'f' || ch == 'n'
|
||||
}
|
||||
+1005
-2
File diff suppressed because it is too large.
Load diff
@@ -29,6 +29,7 @@ const (
|
||||
type LFM2Parser struct {
|
||||
state LFM2ParserState
|
||||
buffer strings.Builder
|
||||
callIndex int
|
||||
hasThinkingSupport bool
|
||||
needsThinkingLeadingTrim bool // trim leading whitespace after <think> tag
|
||||
needsContentLeadingTrim bool // trim leading whitespace after </think> tag
|
||||
@@ -66,6 +67,7 @@ func (p *LFM2Parser) setInitialState(lastMessage *api.Message, thinkValue *api.T
|
||||
|
||||
func (p *LFM2Parser) Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool {
|
||||
p.toolNames = make(map[string]struct{}, len(tools))
|
||||
p.callIndex = 0
|
||||
p.hasTools = len(tools) > 0
|
||||
for _, tool := range tools {
|
||||
if tool.Function.Name != "" {
|
||||
@@ -123,6 +125,11 @@ func (p *LFM2Parser) Add(s string, done bool) (content string, thinking string,
|
||||
}
|
||||
}
|
||||
|
||||
for i := range toolCalls {
|
||||
toolCalls[i].Function.Index = p.callIndex
|
||||
p.callIndex++
|
||||
}
|
||||
|
||||
return contentSb.String(), thinkingSb.String(), toolCalls, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,8 @@ func TestLFM2Parser(t *testing.T) {
|
||||
expectedCalls: []api.ToolCall{
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "get_weather",
|
||||
Index: 0,
|
||||
Name: "get_weather",
|
||||
Arguments: testArgs(map[string]any{
|
||||
"location": "Paris",
|
||||
}),
|
||||
@@ -68,7 +69,8 @@ func TestLFM2Parser(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "get_weather",
|
||||
Index: 1,
|
||||
Name: "get_weather",
|
||||
Arguments: testArgs(map[string]any{
|
||||
"location": "London",
|
||||
}),
|
||||
@@ -205,7 +207,8 @@ func TestLFM2Parser(t *testing.T) {
|
||||
expectedCalls: []api.ToolCall{
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "bash",
|
||||
Index: 0,
|
||||
Name: "bash",
|
||||
Arguments: testArgs(map[string]any{
|
||||
"command": "ls",
|
||||
}),
|
||||
@@ -213,7 +216,8 @@ func TestLFM2Parser(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "bash",
|
||||
Index: 1,
|
||||
Name: "bash",
|
||||
Arguments: testArgs(map[string]any{
|
||||
"command": "pwd",
|
||||
}),
|
||||
|
||||
@@ -44,6 +44,7 @@ type MinistralParser struct {
|
||||
state ministralParserState
|
||||
buffer strings.Builder
|
||||
tools []api.Tool
|
||||
callIndex int
|
||||
hasThinkingSupport bool
|
||||
pendingToolName string // stores tool name while collecting args
|
||||
}
|
||||
@@ -73,6 +74,7 @@ func (p *MinistralParser) setInitialState(lastMessage *api.Message) {
|
||||
|
||||
func (p *MinistralParser) Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool {
|
||||
p.tools = tools
|
||||
p.callIndex = 0
|
||||
p.setInitialState(lastMessage)
|
||||
return tools
|
||||
}
|
||||
@@ -288,6 +290,11 @@ func (p *MinistralParser) Add(s string, done bool) (content string, thinking str
|
||||
}
|
||||
}
|
||||
|
||||
for i := range toolCalls {
|
||||
toolCalls[i].Function.Index = p.callIndex
|
||||
p.callIndex++
|
||||
}
|
||||
|
||||
return contentBuilder.String(), thinkingBuilder.String(), toolCalls, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
@@ -395,6 +396,54 @@ func TestMinistralParserStreaming(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinistralParserAssignsSequentialToolCallIndices(t *testing.T) {
|
||||
parser := &MinistralParser{}
|
||||
parser.Init([]api.Tool{
|
||||
{Function: api.ToolFunction{Name: "get_weather"}},
|
||||
{Function: api.ToolFunction{Name: "get_time"}},
|
||||
}, nil, nil)
|
||||
|
||||
content, thinking, calls, err := parser.Add(
|
||||
`[TOOL_CALLS]get_weather[ARGS]{"location":"NYC"}[TOOL_CALLS]get_time[ARGS]{"timezone":"EST"}`,
|
||||
true,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Add() error = %v", err)
|
||||
}
|
||||
|
||||
if content != "" {
|
||||
t.Fatalf("expected no content, got %q", content)
|
||||
}
|
||||
if thinking != "" {
|
||||
t.Fatalf("expected no thinking, got %q", thinking)
|
||||
}
|
||||
|
||||
expected := []api.ToolCall{
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Index: 0,
|
||||
Name: "get_weather",
|
||||
Arguments: testArgs(map[string]any{
|
||||
"location": "NYC",
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Index: 1,
|
||||
Name: "get_time",
|
||||
Arguments: testArgs(map[string]any{
|
||||
"timezone": "EST",
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(expected, calls, argsComparer); diff != "" {
|
||||
t.Fatalf("tool calls mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinistralParser_Errors(t *testing.T) {
|
||||
t.Run("unknown tool returns error", func(t *testing.T) {
|
||||
p := &MinistralParser{}
|
||||
|
||||
@@ -26,8 +26,9 @@ const (
|
||||
)
|
||||
|
||||
type Olmo3Parser struct {
|
||||
state olmo3ParserState
|
||||
buffer strings.Builder
|
||||
state olmo3ParserState
|
||||
buffer strings.Builder
|
||||
callIndex int
|
||||
}
|
||||
|
||||
func (p *Olmo3Parser) HasToolSupport() bool {
|
||||
@@ -40,6 +41,7 @@ func (p *Olmo3Parser) HasThinkingSupport() bool {
|
||||
|
||||
func (p *Olmo3Parser) Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool {
|
||||
p.state = olmo3StateContent
|
||||
p.callIndex = 0
|
||||
return tools
|
||||
}
|
||||
|
||||
@@ -84,6 +86,11 @@ func (p *Olmo3Parser) Add(s string, done bool) (content string, thinking string,
|
||||
}
|
||||
}
|
||||
|
||||
for i := range allCalls {
|
||||
allCalls[i].Function.Index = p.callIndex
|
||||
p.callIndex++
|
||||
}
|
||||
|
||||
return contentSb.String(), "", allCalls, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -69,12 +69,14 @@ get_weather(location="New York")</function_calls>`,
|
||||
expectedCalls: []api.ToolCall{
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Index: 0,
|
||||
Name: "get_weather",
|
||||
Arguments: testArgs(map[string]any{"location": "San Francisco"}),
|
||||
},
|
||||
},
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Index: 1,
|
||||
Name: "get_weather",
|
||||
Arguments: testArgs(map[string]any{"location": "New York"}),
|
||||
},
|
||||
|
||||
@@ -28,6 +28,7 @@ type Qwen3VLParser struct {
|
||||
state qwenParserState
|
||||
buffer strings.Builder
|
||||
tools []api.Tool
|
||||
callIndex int
|
||||
hasThinkingSupport bool
|
||||
}
|
||||
|
||||
@@ -56,6 +57,7 @@ func (p *Qwen3VLParser) setInitialState(lastMessage *api.Message) {
|
||||
|
||||
func (p *Qwen3VLParser) Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool {
|
||||
p.tools = tools
|
||||
p.callIndex = 0
|
||||
p.setInitialState(lastMessage)
|
||||
return tools
|
||||
}
|
||||
@@ -90,6 +92,11 @@ func (p *Qwen3VLParser) Add(s string, done bool) (content string, thinking strin
|
||||
}
|
||||
}
|
||||
|
||||
for i := range calls {
|
||||
calls[i].Function.Index = p.callIndex
|
||||
p.callIndex++
|
||||
}
|
||||
|
||||
return contentSb.String(), thinkingSb.String(), calls, nil
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user