min-agent 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/dist/agent.js +53 -7
- package/dist/cli/commands/ctx.js +7 -0
- package/dist/cli/commands/index.js +10 -2
- package/dist/cli/commands/setup.js +55 -3
- package/dist/cli/commands/shared.js +10 -1
- package/dist/cli/program.js +7 -1
- package/dist/cli/setup/detect.js +17 -0
- package/dist/cli/setup/flags.js +12 -0
- package/dist/cli/setup/flow.js +108 -0
- package/dist/cli/setup/provider-form.js +102 -0
- package/dist/cli/setup/ui.js +534 -0
- package/dist/config.js +52 -159
- package/dist/context-window.js +33 -23
- package/dist/ctx-cli.js +30 -0
- package/dist/ctx.js +80 -0
- package/dist/ollama-model.js +234 -0
- package/dist/ollama-openai-bridge.js +383 -0
- package/dist/serve/routes-meta.js +35 -0
- package/dist/thinking-wire.js +15 -4
- package/dist/thinking.js +26 -2
- package/dist/tui/App.js +18 -6
- package/dist/tui/CtxPicker.js +68 -0
- package/dist/tui/InputBar.js +4 -2
- package/dist/tui/ThinkPicker.js +4 -6
- package/dist/tui/index.js +7 -1
- package/dist/tui/slash-commands.js +6 -0
- package/dist/tui/slash-handler.js +27 -1
- package/dist/tui-chat.js +25 -3
- package/docs/API.md +19 -2
- package/docs/superpowers/plans/2026-08-23-cli-setup.md +501 -0
- package/docs/superpowers/specs/2026-08-23-cli-setup-design.md +282 -0
- package/package.json +1 -1
- package/skills/self-config/SKILL.md +3 -1
- package/skills/self-config/reference.md +4 -3
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { readFileSync, mkdirSync, existsSync } from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { getActiveProvider, getConfigDir, getEffectiveConfig } from "./config.js";
|
|
4
|
+
import { atomicWriteFileSync } from "./tools/atomic-file.js";
|
|
5
|
+
const OLLAMA_ENTRY_VERSION = 2;
|
|
6
|
+
const CACHE_TTL = 7 * 24 * 60 * 60 * 1000;
|
|
7
|
+
const NEGATIVE_TTL = 60 * 60 * 1000;
|
|
8
|
+
const FETCH_TIMEOUT_MS = 8000;
|
|
9
|
+
const OLLAMA_MIN_CONTEXT = 512;
|
|
10
|
+
/** Native Ollama default when /api/show has no num_ctx and no architecture length. */
|
|
11
|
+
export const OLLAMA_DEFAULT_CONTEXT_WINDOW = 2048;
|
|
12
|
+
/** Architecture max is not the loaded window; cap what we request/display for agents. */
|
|
13
|
+
export const OLLAMA_AGENT_CONTEXT_CAP = 32_768;
|
|
14
|
+
const memoryCache = new Map();
|
|
15
|
+
const inFlight = new Map();
|
|
16
|
+
const negativeCache = new Map();
|
|
17
|
+
function isRecord(value) {
|
|
18
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
19
|
+
}
|
|
20
|
+
export function ollamaNativeBase(baseURL) {
|
|
21
|
+
return baseURL.replace(/\/$/, "").replace(/\/v1$/i, "");
|
|
22
|
+
}
|
|
23
|
+
export function ollamaModelCacheKey(baseURL, modelId) {
|
|
24
|
+
let host = "";
|
|
25
|
+
try {
|
|
26
|
+
host = new URL(baseURL).hostname.toLowerCase();
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
host = ollamaNativeBase(baseURL).toLowerCase();
|
|
30
|
+
}
|
|
31
|
+
const model = modelId.trim().toLowerCase();
|
|
32
|
+
return host ? `${host}::${model}` : model;
|
|
33
|
+
}
|
|
34
|
+
function asContextTokens(value) {
|
|
35
|
+
const n = typeof value === "string" && value.trim() !== "" ? Number(value) : value;
|
|
36
|
+
if (typeof n !== "number" || !Number.isFinite(n) || n < OLLAMA_MIN_CONTEXT)
|
|
37
|
+
return undefined;
|
|
38
|
+
return Math.floor(n);
|
|
39
|
+
}
|
|
40
|
+
function contextFromModelInfo(info) {
|
|
41
|
+
const found = [];
|
|
42
|
+
for (const [key, value] of Object.entries(info)) {
|
|
43
|
+
if (key === "context_length" || key.endsWith(".context_length")) {
|
|
44
|
+
const n = asContextTokens(value);
|
|
45
|
+
if (n != null)
|
|
46
|
+
found.push(n);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (found.length === 0)
|
|
50
|
+
return undefined;
|
|
51
|
+
return Math.max(...found);
|
|
52
|
+
}
|
|
53
|
+
function numCtxFromParameters(raw) {
|
|
54
|
+
if (typeof raw === "number")
|
|
55
|
+
return asContextTokens(raw);
|
|
56
|
+
if (isRecord(raw))
|
|
57
|
+
return asContextTokens(raw.num_ctx);
|
|
58
|
+
if (typeof raw !== "string")
|
|
59
|
+
return undefined;
|
|
60
|
+
const match = raw.match(/(?:^|\n)\s*num_ctx\s+(\d+)/);
|
|
61
|
+
if (match?.[1])
|
|
62
|
+
return asContextTokens(Number(match[1]));
|
|
63
|
+
return asContextTokens(raw);
|
|
64
|
+
}
|
|
65
|
+
function thinkingFromCapabilities(raw) {
|
|
66
|
+
if (!Array.isArray(raw))
|
|
67
|
+
return null;
|
|
68
|
+
return raw.some((item) => String(item).toLowerCase() === "thinking");
|
|
69
|
+
}
|
|
70
|
+
export function ollamaOperationalContext(numCtx, architecture) {
|
|
71
|
+
if (numCtx != null)
|
|
72
|
+
return numCtx;
|
|
73
|
+
if (architecture != null)
|
|
74
|
+
return Math.min(architecture, OLLAMA_AGENT_CONTEXT_CAP);
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
export function applyOllamaChatOptions(body, numCtx) {
|
|
78
|
+
const prev = isRecord(body.options) ? body.options : {};
|
|
79
|
+
return {
|
|
80
|
+
...body,
|
|
81
|
+
options: { ...prev, num_ctx: numCtx },
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
export function ollamaThinkValue(spec, modelThinking) {
|
|
85
|
+
if (modelThinking === false)
|
|
86
|
+
return false;
|
|
87
|
+
if (!spec)
|
|
88
|
+
return undefined;
|
|
89
|
+
if (!spec.thinkingEnabled)
|
|
90
|
+
return false;
|
|
91
|
+
const effort = spec.reasoningEffort;
|
|
92
|
+
if (effort === "low" || effort === "medium" || effort === "high")
|
|
93
|
+
return effort;
|
|
94
|
+
if (effort === "none")
|
|
95
|
+
return false;
|
|
96
|
+
return "high";
|
|
97
|
+
}
|
|
98
|
+
export function resolveOllamaNumCtx(modelId, requestUrl) {
|
|
99
|
+
const provider = getActiveProvider(getEffectiveConfig());
|
|
100
|
+
if (typeof provider?.contextWindow === "number" &&
|
|
101
|
+
Number.isFinite(provider.contextWindow) &&
|
|
102
|
+
provider.contextWindow >= OLLAMA_MIN_CONTEXT) {
|
|
103
|
+
return Math.floor(provider.contextWindow);
|
|
104
|
+
}
|
|
105
|
+
const cached = getCachedOllamaModel(modelId, requestUrl ?? provider?.baseURL);
|
|
106
|
+
return cached?.contextWindow ?? OLLAMA_DEFAULT_CONTEXT_WINDOW;
|
|
107
|
+
}
|
|
108
|
+
export function parseOllamaShow(payload, modelId) {
|
|
109
|
+
const root = isRecord(payload) ? payload : {};
|
|
110
|
+
const modelInfo = isRecord(root.model_info) ? root.model_info : {};
|
|
111
|
+
const contextWindow = ollamaOperationalContext(numCtxFromParameters(root.parameters), contextFromModelInfo(modelInfo));
|
|
112
|
+
return {
|
|
113
|
+
version: OLLAMA_ENTRY_VERSION,
|
|
114
|
+
modelId,
|
|
115
|
+
...(contextWindow != null ? { contextWindow } : {}),
|
|
116
|
+
thinking: thinkingFromCapabilities(root.capabilities),
|
|
117
|
+
timestamp: Date.now(),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
export function thinkingChoicesFromOllama(info) {
|
|
121
|
+
if (info.thinking === false)
|
|
122
|
+
return ["off"];
|
|
123
|
+
return ["off", "low", "medium", "high", "max"];
|
|
124
|
+
}
|
|
125
|
+
function cacheFile() {
|
|
126
|
+
return path.join(getConfigDir(), "ollama-model-cache.json");
|
|
127
|
+
}
|
|
128
|
+
function loadDiskCache() {
|
|
129
|
+
const file = cacheFile();
|
|
130
|
+
if (!existsSync(file))
|
|
131
|
+
return {};
|
|
132
|
+
try {
|
|
133
|
+
const parsed = JSON.parse(readFileSync(file, "utf-8"));
|
|
134
|
+
if (!isRecord(parsed))
|
|
135
|
+
return {};
|
|
136
|
+
const out = {};
|
|
137
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
138
|
+
if (!isRecord(value) || value.version !== OLLAMA_ENTRY_VERSION)
|
|
139
|
+
continue;
|
|
140
|
+
if (typeof value.modelId !== "string")
|
|
141
|
+
continue;
|
|
142
|
+
const thinking = value.thinking === true || value.thinking === false ? value.thinking : null;
|
|
143
|
+
const contextWindow = asContextTokens(value.contextWindow);
|
|
144
|
+
const timestamp = typeof value.timestamp === "number" ? value.timestamp : 0;
|
|
145
|
+
out[key] = {
|
|
146
|
+
version: OLLAMA_ENTRY_VERSION,
|
|
147
|
+
modelId: value.modelId,
|
|
148
|
+
...(contextWindow != null ? { contextWindow } : {}),
|
|
149
|
+
thinking,
|
|
150
|
+
timestamp,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return {};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function saveDiskCache(cache) {
|
|
160
|
+
const file = cacheFile();
|
|
161
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
162
|
+
atomicWriteFileSync(file, JSON.stringify(cache));
|
|
163
|
+
}
|
|
164
|
+
export function clearOllamaModelCache() {
|
|
165
|
+
memoryCache.clear();
|
|
166
|
+
inFlight.clear();
|
|
167
|
+
negativeCache.clear();
|
|
168
|
+
}
|
|
169
|
+
export function getCachedOllamaModel(modelId, baseURL) {
|
|
170
|
+
const key = ollamaModelCacheKey(baseURL ?? "", modelId);
|
|
171
|
+
const memory = memoryCache.get(key);
|
|
172
|
+
if (memory && memory.version === OLLAMA_ENTRY_VERSION && Date.now() - memory.timestamp <= CACHE_TTL)
|
|
173
|
+
return memory;
|
|
174
|
+
const disk = loadDiskCache()[key];
|
|
175
|
+
if (!disk || disk.version !== OLLAMA_ENTRY_VERSION || Date.now() - disk.timestamp > CACHE_TTL)
|
|
176
|
+
return null;
|
|
177
|
+
memoryCache.set(key, disk);
|
|
178
|
+
return disk;
|
|
179
|
+
}
|
|
180
|
+
function setCache(key, entry) {
|
|
181
|
+
memoryCache.set(key, entry);
|
|
182
|
+
const cache = loadDiskCache();
|
|
183
|
+
cache[key] = entry;
|
|
184
|
+
saveDiskCache(cache);
|
|
185
|
+
}
|
|
186
|
+
async function fetchShow(baseURL, modelId) {
|
|
187
|
+
try {
|
|
188
|
+
const response = await fetch(`${ollamaNativeBase(baseURL)}/api/show`, {
|
|
189
|
+
method: "POST",
|
|
190
|
+
headers: { "Content-Type": "application/json" },
|
|
191
|
+
body: JSON.stringify({ name: modelId }),
|
|
192
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
193
|
+
});
|
|
194
|
+
if (!response.ok)
|
|
195
|
+
return null;
|
|
196
|
+
return await response.json();
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Load context window and thinking capability from a user-configured Ollama
|
|
204
|
+
* provider via native POST /api/show. Cached 7 days after a successful lookup.
|
|
205
|
+
*/
|
|
206
|
+
export async function getOllamaModel(modelId, baseURL) {
|
|
207
|
+
const url = baseURL ?? "";
|
|
208
|
+
const key = ollamaModelCacheKey(url, modelId);
|
|
209
|
+
const cached = getCachedOllamaModel(modelId, url);
|
|
210
|
+
if (cached)
|
|
211
|
+
return cached;
|
|
212
|
+
const negAt = negativeCache.get(key);
|
|
213
|
+
if (negAt !== undefined && Date.now() - negAt <= NEGATIVE_TTL)
|
|
214
|
+
return null;
|
|
215
|
+
const pending = inFlight.get(key);
|
|
216
|
+
if (pending)
|
|
217
|
+
return pending;
|
|
218
|
+
const probing = fetchShow(url, modelId).then((payload) => {
|
|
219
|
+
if (!payload) {
|
|
220
|
+
negativeCache.set(key, Date.now());
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
const entry = parseOllamaShow(payload, modelId);
|
|
224
|
+
setCache(key, entry);
|
|
225
|
+
return entry;
|
|
226
|
+
});
|
|
227
|
+
inFlight.set(key, probing);
|
|
228
|
+
try {
|
|
229
|
+
return await probing;
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
inFlight.delete(key);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { applyOllamaChatOptions, getCachedOllamaModel, ollamaNativeBase, ollamaThinkValue, resolveOllamaNumCtx, } from "./ollama-model.js";
|
|
2
|
+
function isRecord(value) {
|
|
3
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
4
|
+
}
|
|
5
|
+
export function requestHref(input) {
|
|
6
|
+
return typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
7
|
+
}
|
|
8
|
+
export function isOllamaChatCompletionsUrl(href) {
|
|
9
|
+
try {
|
|
10
|
+
return /\/chat\/completions\/?$/i.test(new URL(href).pathname);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return /\/chat\/completions\/?$/i.test(href);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function ollamaNativeChatUrl(href) {
|
|
17
|
+
const stripped = href.replace(/\/v1\/chat\/completions\/?$/i, "").replace(/\/chat\/completions\/?$/i, "");
|
|
18
|
+
return `${ollamaNativeBase(stripped)}/api/chat`;
|
|
19
|
+
}
|
|
20
|
+
export async function readFetchBody(body) {
|
|
21
|
+
if (body == null)
|
|
22
|
+
return null;
|
|
23
|
+
if (typeof body === "string")
|
|
24
|
+
return body;
|
|
25
|
+
if (body instanceof Uint8Array)
|
|
26
|
+
return new TextDecoder().decode(body);
|
|
27
|
+
if (body instanceof ArrayBuffer)
|
|
28
|
+
return new TextDecoder().decode(body);
|
|
29
|
+
if (Buffer?.isBuffer(body))
|
|
30
|
+
return body.toString("utf8");
|
|
31
|
+
if (typeof Blob !== "undefined" && body instanceof Blob)
|
|
32
|
+
return await body.text();
|
|
33
|
+
if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) {
|
|
34
|
+
const reader = body.getReader();
|
|
35
|
+
const chunks = [];
|
|
36
|
+
for (;;) {
|
|
37
|
+
const { done, value } = await reader.read();
|
|
38
|
+
if (done)
|
|
39
|
+
break;
|
|
40
|
+
if (value)
|
|
41
|
+
chunks.push(value);
|
|
42
|
+
}
|
|
43
|
+
const total = chunks.reduce((n, c) => n + c.length, 0);
|
|
44
|
+
const out = new Uint8Array(total);
|
|
45
|
+
let offset = 0;
|
|
46
|
+
for (const chunk of chunks) {
|
|
47
|
+
out.set(chunk, offset);
|
|
48
|
+
offset += chunk.length;
|
|
49
|
+
}
|
|
50
|
+
return new TextDecoder().decode(out);
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
function dataUrlBase64(url) {
|
|
55
|
+
const match = url.match(/^data:[^;]+;base64,(.+)$/s);
|
|
56
|
+
return match?.[1] ?? null;
|
|
57
|
+
}
|
|
58
|
+
function textFromContent(content) {
|
|
59
|
+
if (typeof content === "string")
|
|
60
|
+
return { text: content, images: [] };
|
|
61
|
+
if (!Array.isArray(content))
|
|
62
|
+
return { text: content == null ? "" : JSON.stringify(content), images: [] };
|
|
63
|
+
const texts = [];
|
|
64
|
+
const images = [];
|
|
65
|
+
for (const part of content) {
|
|
66
|
+
if (!isRecord(part))
|
|
67
|
+
continue;
|
|
68
|
+
if (part.type === "text" && typeof part.text === "string")
|
|
69
|
+
texts.push(part.text);
|
|
70
|
+
else if (part.type === "image_url") {
|
|
71
|
+
const image = isRecord(part.image_url) ? part.image_url.url : undefined;
|
|
72
|
+
if (typeof image === "string") {
|
|
73
|
+
const b64 = dataUrlBase64(image);
|
|
74
|
+
if (b64)
|
|
75
|
+
images.push(b64);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
else if (typeof part.text === "string")
|
|
79
|
+
texts.push(part.text);
|
|
80
|
+
}
|
|
81
|
+
return { text: texts.join("\n"), images };
|
|
82
|
+
}
|
|
83
|
+
function argsAsObject(raw) {
|
|
84
|
+
if (isRecord(raw))
|
|
85
|
+
return raw;
|
|
86
|
+
if (typeof raw !== "string" || !raw.trim())
|
|
87
|
+
return {};
|
|
88
|
+
try {
|
|
89
|
+
const parsed = JSON.parse(raw);
|
|
90
|
+
return isRecord(parsed) ? parsed : { value: parsed };
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return { value: raw };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function argsAsString(raw) {
|
|
97
|
+
if (typeof raw === "string")
|
|
98
|
+
return raw;
|
|
99
|
+
try {
|
|
100
|
+
return JSON.stringify(raw ?? {});
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return "{}";
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function convertToolCallsOut(raw) {
|
|
107
|
+
if (!Array.isArray(raw) || raw.length === 0)
|
|
108
|
+
return undefined;
|
|
109
|
+
return raw.map((item) => {
|
|
110
|
+
const rec = isRecord(item) ? item : {};
|
|
111
|
+
const fn = isRecord(rec.function) ? rec.function : rec;
|
|
112
|
+
return {
|
|
113
|
+
function: {
|
|
114
|
+
name: typeof fn.name === "string" ? fn.name : "",
|
|
115
|
+
arguments: argsAsObject(fn.arguments),
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
export function openaiMessagesToOllama(messages) {
|
|
121
|
+
if (!Array.isArray(messages))
|
|
122
|
+
return [];
|
|
123
|
+
return messages.map((item) => {
|
|
124
|
+
const rec = isRecord(item) ? item : {};
|
|
125
|
+
const { text, images } = textFromContent(rec.content);
|
|
126
|
+
const out = {
|
|
127
|
+
role: typeof rec.role === "string" ? rec.role : "user",
|
|
128
|
+
content: text,
|
|
129
|
+
};
|
|
130
|
+
if (images.length > 0)
|
|
131
|
+
out.images = images;
|
|
132
|
+
const toolCalls = convertToolCallsOut(rec.tool_calls);
|
|
133
|
+
if (toolCalls)
|
|
134
|
+
out.tool_calls = toolCalls;
|
|
135
|
+
return out;
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
function copySampling(body) {
|
|
139
|
+
const options = isRecord(body.options) ? { ...body.options } : {};
|
|
140
|
+
if (typeof body.temperature === "number")
|
|
141
|
+
options.temperature = body.temperature;
|
|
142
|
+
if (typeof body.top_p === "number")
|
|
143
|
+
options.top_p = body.top_p;
|
|
144
|
+
if (typeof body.seed === "number")
|
|
145
|
+
options.seed = body.seed;
|
|
146
|
+
if (typeof body.max_tokens === "number")
|
|
147
|
+
options.num_predict = body.max_tokens;
|
|
148
|
+
if (typeof body.max_completion_tokens === "number")
|
|
149
|
+
options.num_predict = body.max_completion_tokens;
|
|
150
|
+
if (Array.isArray(body.stop) || typeof body.stop === "string")
|
|
151
|
+
options.stop = body.stop;
|
|
152
|
+
return options;
|
|
153
|
+
}
|
|
154
|
+
export function openaiChatToOllamaNative(body, opts) {
|
|
155
|
+
const native = {
|
|
156
|
+
model: body.model,
|
|
157
|
+
messages: openaiMessagesToOllama(body.messages),
|
|
158
|
+
stream: body.stream !== false,
|
|
159
|
+
options: copySampling(body),
|
|
160
|
+
};
|
|
161
|
+
if (Array.isArray(body.tools) && body.tools.length > 0)
|
|
162
|
+
native.tools = body.tools;
|
|
163
|
+
if (body.format != null)
|
|
164
|
+
native.format = body.format;
|
|
165
|
+
if (opts.think !== undefined)
|
|
166
|
+
native.think = opts.think;
|
|
167
|
+
return applyOllamaChatOptions(native, opts.numCtx);
|
|
168
|
+
}
|
|
169
|
+
function finishReasonFor(doneReason, toolCalls) {
|
|
170
|
+
if (Array.isArray(toolCalls) && toolCalls.length > 0)
|
|
171
|
+
return "tool_calls";
|
|
172
|
+
if (doneReason === "length" || doneReason === "max_tokens")
|
|
173
|
+
return "length";
|
|
174
|
+
return "stop";
|
|
175
|
+
}
|
|
176
|
+
function usageFromNative(row) {
|
|
177
|
+
const prompt = typeof row.prompt_eval_count === "number" ? row.prompt_eval_count : undefined;
|
|
178
|
+
const completion = typeof row.eval_count === "number" ? row.eval_count : undefined;
|
|
179
|
+
if (prompt == null && completion == null)
|
|
180
|
+
return undefined;
|
|
181
|
+
const promptTokens = prompt ?? 0;
|
|
182
|
+
const completionTokens = completion ?? 0;
|
|
183
|
+
return {
|
|
184
|
+
prompt_tokens: promptTokens,
|
|
185
|
+
completion_tokens: completionTokens,
|
|
186
|
+
total_tokens: promptTokens + completionTokens,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
function openaiToolCallDeltas(raw) {
|
|
190
|
+
if (!Array.isArray(raw) || raw.length === 0)
|
|
191
|
+
return undefined;
|
|
192
|
+
return raw.map((item, index) => {
|
|
193
|
+
const rec = isRecord(item) ? item : {};
|
|
194
|
+
const fn = isRecord(rec.function) ? rec.function : rec;
|
|
195
|
+
const id = typeof rec.id === "string" && rec.id ? rec.id : `ollama_call_${index}`;
|
|
196
|
+
return {
|
|
197
|
+
index,
|
|
198
|
+
id,
|
|
199
|
+
type: "function",
|
|
200
|
+
function: {
|
|
201
|
+
name: typeof fn.name === "string" ? fn.name : "",
|
|
202
|
+
arguments: argsAsString(fn.arguments),
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
export function nativeChatRowToOpenAIChunk(row, state) {
|
|
208
|
+
const message = isRecord(row.message) ? row.message : {};
|
|
209
|
+
const content = typeof message.content === "string" ? message.content : "";
|
|
210
|
+
const thinking = typeof message.thinking === "string" ? message.thinking : "";
|
|
211
|
+
const toolCalls = openaiToolCallDeltas(message.tool_calls);
|
|
212
|
+
const done = row.done === true;
|
|
213
|
+
const delta = {};
|
|
214
|
+
if (!state.sentRole) {
|
|
215
|
+
delta.role = "assistant";
|
|
216
|
+
state.sentRole = true;
|
|
217
|
+
}
|
|
218
|
+
if (thinking)
|
|
219
|
+
delta.reasoning = thinking;
|
|
220
|
+
if (content)
|
|
221
|
+
delta.content = content;
|
|
222
|
+
if (toolCalls)
|
|
223
|
+
delta.tool_calls = toolCalls;
|
|
224
|
+
const chunk = {
|
|
225
|
+
id: "chatcmpl-ollama",
|
|
226
|
+
object: "chat.completion.chunk",
|
|
227
|
+
created: Math.floor(Date.now() / 1000),
|
|
228
|
+
model: typeof row.model === "string" ? row.model : "",
|
|
229
|
+
choices: [
|
|
230
|
+
{
|
|
231
|
+
index: 0,
|
|
232
|
+
delta,
|
|
233
|
+
finish_reason: done ? finishReasonFor(row.done_reason, message.tool_calls) : null,
|
|
234
|
+
},
|
|
235
|
+
],
|
|
236
|
+
};
|
|
237
|
+
const usage = usageFromNative(row);
|
|
238
|
+
if (usage)
|
|
239
|
+
chunk.usage = usage;
|
|
240
|
+
return chunk;
|
|
241
|
+
}
|
|
242
|
+
export function nativeChatToOpenAIResponse(row) {
|
|
243
|
+
const message = isRecord(row.message) ? row.message : {};
|
|
244
|
+
const content = typeof message.content === "string" ? message.content : "";
|
|
245
|
+
const thinking = typeof message.thinking === "string" ? message.thinking : "";
|
|
246
|
+
const toolCalls = openaiToolCallDeltas(message.tool_calls);
|
|
247
|
+
const assistant = { role: "assistant", content };
|
|
248
|
+
if (thinking)
|
|
249
|
+
assistant.reasoning = thinking;
|
|
250
|
+
if (toolCalls)
|
|
251
|
+
assistant.tool_calls = toolCalls;
|
|
252
|
+
const out = {
|
|
253
|
+
id: "chatcmpl-ollama",
|
|
254
|
+
object: "chat.completion",
|
|
255
|
+
created: Math.floor(Date.now() / 1000),
|
|
256
|
+
model: typeof row.model === "string" ? row.model : "",
|
|
257
|
+
choices: [
|
|
258
|
+
{
|
|
259
|
+
index: 0,
|
|
260
|
+
message: assistant,
|
|
261
|
+
finish_reason: finishReasonFor(row.done_reason, message.tool_calls),
|
|
262
|
+
},
|
|
263
|
+
],
|
|
264
|
+
};
|
|
265
|
+
const usage = usageFromNative(row);
|
|
266
|
+
if (usage)
|
|
267
|
+
out.usage = usage;
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
function sseLine(payload) {
|
|
271
|
+
return `data: ${JSON.stringify(payload)}\n\n`;
|
|
272
|
+
}
|
|
273
|
+
export function transformOllamaNdjsonToSse(source) {
|
|
274
|
+
const decoder = new TextDecoder();
|
|
275
|
+
const encoder = new TextEncoder();
|
|
276
|
+
const reader = source.getReader();
|
|
277
|
+
const state = { sentRole: false };
|
|
278
|
+
let pending = "";
|
|
279
|
+
return new ReadableStream({
|
|
280
|
+
async pull(controller) {
|
|
281
|
+
for (;;) {
|
|
282
|
+
const { done, value } = await reader.read();
|
|
283
|
+
if (done) {
|
|
284
|
+
pending += decoder.decode();
|
|
285
|
+
const leftover = pending.trim();
|
|
286
|
+
if (leftover) {
|
|
287
|
+
try {
|
|
288
|
+
const parsed = JSON.parse(leftover);
|
|
289
|
+
if (isRecord(parsed))
|
|
290
|
+
controller.enqueue(encoder.encode(sseLine(nativeChatRowToOpenAIChunk(parsed, state))));
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
/* ignore trailing garbage */
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
297
|
+
controller.close();
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
pending += decoder.decode(value, { stream: true });
|
|
301
|
+
const nl = pending.lastIndexOf("\n");
|
|
302
|
+
if (nl < 0)
|
|
303
|
+
continue;
|
|
304
|
+
const complete = pending.slice(0, nl + 1);
|
|
305
|
+
pending = pending.slice(nl + 1);
|
|
306
|
+
const out = [];
|
|
307
|
+
for (const line of complete.split("\n")) {
|
|
308
|
+
const trimmed = line.trim();
|
|
309
|
+
if (!trimmed)
|
|
310
|
+
continue;
|
|
311
|
+
try {
|
|
312
|
+
const parsed = JSON.parse(trimmed);
|
|
313
|
+
if (isRecord(parsed))
|
|
314
|
+
out.push(sseLine(nativeChatRowToOpenAIChunk(parsed, state)));
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
/* skip malformed native lines */
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (out.length > 0) {
|
|
321
|
+
controller.enqueue(encoder.encode(out.join("")));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
cancel(reason) {
|
|
327
|
+
return reader.cancel(reason);
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
function jsonHeaders(headers) {
|
|
332
|
+
const next = new Headers(headers);
|
|
333
|
+
next.set("content-type", "application/json");
|
|
334
|
+
return next;
|
|
335
|
+
}
|
|
336
|
+
function sseHeaders(headers) {
|
|
337
|
+
const next = new Headers(headers);
|
|
338
|
+
next.set("content-type", "text/event-stream");
|
|
339
|
+
next.delete("content-length");
|
|
340
|
+
return next;
|
|
341
|
+
}
|
|
342
|
+
export async function fetchOllamaNativeChat(inner, input, init, body, spec) {
|
|
343
|
+
const href = requestHref(input);
|
|
344
|
+
if (!isOllamaChatCompletionsUrl(href)) {
|
|
345
|
+
return inner(input, { ...init, body: JSON.stringify(body) });
|
|
346
|
+
}
|
|
347
|
+
const modelId = typeof body.model === "string" ? body.model : "";
|
|
348
|
+
const cached = getCachedOllamaModel(modelId, href);
|
|
349
|
+
const nativeBody = openaiChatToOllamaNative(body, {
|
|
350
|
+
numCtx: resolveOllamaNumCtx(modelId, href),
|
|
351
|
+
think: ollamaThinkValue(spec, cached?.thinking ?? null),
|
|
352
|
+
});
|
|
353
|
+
const response = await inner(ollamaNativeChatUrl(href), {
|
|
354
|
+
...init,
|
|
355
|
+
method: init.method ?? "POST",
|
|
356
|
+
headers: { ...Object.fromEntries(new Headers(init.headers).entries()), "Content-Type": "application/json" },
|
|
357
|
+
body: JSON.stringify(nativeBody),
|
|
358
|
+
});
|
|
359
|
+
if (response.status >= 400)
|
|
360
|
+
return response;
|
|
361
|
+
const streaming = body.stream !== false;
|
|
362
|
+
if (!streaming) {
|
|
363
|
+
try {
|
|
364
|
+
const payload = await response.json();
|
|
365
|
+
const converted = isRecord(payload) ? nativeChatToOpenAIResponse(payload) : nativeChatToOpenAIResponse({});
|
|
366
|
+
return new Response(JSON.stringify(converted), {
|
|
367
|
+
status: response.status,
|
|
368
|
+
statusText: response.statusText,
|
|
369
|
+
headers: jsonHeaders(response.headers),
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
return response;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (!response.body)
|
|
377
|
+
return response;
|
|
378
|
+
return new Response(transformOllamaNdjsonToSse(response.body), {
|
|
379
|
+
status: response.status,
|
|
380
|
+
statusText: response.statusText,
|
|
381
|
+
headers: sseHeaders(response.headers),
|
|
382
|
+
});
|
|
383
|
+
}
|
|
@@ -5,6 +5,7 @@ import { readScope } from "../scope.js";
|
|
|
5
5
|
import { getWorkingTreeDiff } from "../tui/diff-view.js";
|
|
6
6
|
import { scanProject } from "../code-mode.js";
|
|
7
7
|
import { getContextWindowInfo } from "../context-window.js";
|
|
8
|
+
import { parseCtxChoice, setOllamaContextChoice, ctxPayload, CTX_CHOICES } from "../ctx.js";
|
|
8
9
|
import { getModelPrice, estimateCost } from "../pricing.js";
|
|
9
10
|
import { checkForUpdate } from "../updater.js";
|
|
10
11
|
import { sendJson, sendJsonError, readJsonBody, readStringPathList, sandboxPayload, permissionPayload, thinkingPayload, memoryPayload, } from "./common.js";
|
|
@@ -209,6 +210,40 @@ export const handleMetaRoutes = async (req, res, ctx, url, pathname) => {
|
|
|
209
210
|
context_window: info.tokens,
|
|
210
211
|
source: info.source,
|
|
211
212
|
model: provider?.defaultModel ?? null,
|
|
213
|
+
...ctxPayload(),
|
|
214
|
+
});
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
if (req.method === "POST" && pathname === "/v1/context") {
|
|
218
|
+
const parsed = await readJsonBody(req);
|
|
219
|
+
if (!parsed.ok) {
|
|
220
|
+
sendJsonError(res, parsed);
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
const choice = typeof parsed.value.level === "string" ? parseCtxChoice(parsed.value.level) : null;
|
|
224
|
+
if (!choice) {
|
|
225
|
+
sendJson(res, 400, {
|
|
226
|
+
error: "invalid_context_level",
|
|
227
|
+
detail: `level must be one of: ${CTX_CHOICES.join(", ")}`,
|
|
228
|
+
});
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
const result = setOllamaContextChoice(choice);
|
|
232
|
+
if (!result.ok) {
|
|
233
|
+
sendJson(res, 400, {
|
|
234
|
+
error: "ctx_not_supported",
|
|
235
|
+
detail: "context levels are only available for an Ollama provider",
|
|
236
|
+
});
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
const provider = getActiveProvider(getEffectiveConfig());
|
|
240
|
+
const info = await getContextWindowInfo(provider?.defaultModel);
|
|
241
|
+
sendJson(res, 200, {
|
|
242
|
+
ok: true,
|
|
243
|
+
context_window: info.tokens,
|
|
244
|
+
source: info.source,
|
|
245
|
+
model: provider?.defaultModel ?? null,
|
|
246
|
+
...ctxPayload(),
|
|
212
247
|
});
|
|
213
248
|
return true;
|
|
214
249
|
}
|