@giovannijecha/jecode 0.1.5-rc.2
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/LICENSE +21 -0
- package/README.md +264 -0
- package/bin/jecode.js +11 -0
- package/dist/atomic.js +78 -0
- package/dist/batch-view.js +17 -0
- package/dist/batch.js +100 -0
- package/dist/cli-info.js +40 -0
- package/dist/commands.js +171 -0
- package/dist/config.js +97 -0
- package/dist/controller.js +90 -0
- package/dist/credential-commands.js +134 -0
- package/dist/credential-safety.js +86 -0
- package/dist/credentials.js +124 -0
- package/dist/main.js +7 -0
- package/dist/ollama-settings-command.js +75 -0
- package/dist/prompt.js +29 -0
- package/dist/provider-commands.js +236 -0
- package/dist/provider-errors.js +10 -0
- package/dist/providers/anthropic-stream.js +111 -0
- package/dist/providers/anthropic-wire.js +79 -0
- package/dist/providers/anthropic.js +77 -0
- package/dist/providers/catalog.js +37 -0
- package/dist/providers/http.js +219 -0
- package/dist/providers/index.js +18 -0
- package/dist/providers/ollama-endpoint.js +40 -0
- package/dist/providers/ollama-stream.js +60 -0
- package/dist/providers/ollama-wire.js +95 -0
- package/dist/providers/ollama.js +87 -0
- package/dist/providers/openai-stream.js +48 -0
- package/dist/providers/openai-wire.js +112 -0
- package/dist/providers/openai.js +70 -0
- package/dist/providers/sse.js +81 -0
- package/dist/providers/stream-limits.js +11 -0
- package/dist/session.js +3 -0
- package/dist/settings-command.js +202 -0
- package/dist/settings.js +98 -0
- package/dist/start.js +47 -0
- package/dist/tools/args.js +38 -0
- package/dist/tools/fs.js +277 -0
- package/dist/tools/index.js +39 -0
- package/dist/tools/paths.js +122 -0
- package/dist/tools/search.js +213 -0
- package/dist/tools/shell.js +139 -0
- package/dist/tools/text-boundary.js +102 -0
- package/dist/tools/types.js +1 -0
- package/dist/transcript-export.js +11 -0
- package/dist/transcript.js +49 -0
- package/dist/tui/activity.js +11 -0
- package/dist/tui/app-input.js +155 -0
- package/dist/tui/app-state.js +17 -0
- package/dist/tui/app-workflows.js +105 -0
- package/dist/tui/app.js +215 -0
- package/dist/tui/approve.js +67 -0
- package/dist/tui/blocks.js +23 -0
- package/dist/tui/complete.js +54 -0
- package/dist/tui/components/command-menu.js +17 -0
- package/dist/tui/components/composer.js +47 -0
- package/dist/tui/components/dock.js +10 -0
- package/dist/tui/components/footer.js +21 -0
- package/dist/tui/components/menu.js +38 -0
- package/dist/tui/components/messages.js +43 -0
- package/dist/tui/components/misc.js +22 -0
- package/dist/tui/components/status.js +45 -0
- package/dist/tui/components/tool.js +85 -0
- package/dist/tui/components/types.js +1 -0
- package/dist/tui/editor.js +105 -0
- package/dist/tui/feedback.js +74 -0
- package/dist/tui/field.js +60 -0
- package/dist/tui/frame.js +33 -0
- package/dist/tui/input.js +52 -0
- package/dist/tui/keys.js +177 -0
- package/dist/tui/modal.js +24 -0
- package/dist/tui/overlay.js +93 -0
- package/dist/tui/picker.js +99 -0
- package/dist/tui/screen.js +94 -0
- package/dist/tui/scroll.js +7 -0
- package/dist/tui/session-view.js +49 -0
- package/dist/tui/transcript-view.js +134 -0
- package/dist/tui/turn.js +255 -0
- package/dist/tui/view.js +88 -0
- package/dist/tui/workspace.js +59 -0
- package/dist/types.js +9 -0
- package/dist/ui/diff.js +109 -0
- package/dist/ui/highlight.js +158 -0
- package/dist/ui/inline.js +39 -0
- package/dist/ui/markdown.js +147 -0
- package/dist/ui/render.js +232 -0
- package/dist/ui/table.js +127 -0
- package/dist/ui/terminal-text.js +42 -0
- package/dist/ui/theme.js +25 -0
- package/dist/ui/width.js +196 -0
- package/dist/usage.js +30 -0
- package/dist/user-data.js +29 -0
- package/package.json +56 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Anthropic Messages API, spoken directly.
|
|
2
|
+
//
|
|
3
|
+
// Wire contract (verified 2026-08-29): POST /v1/messages, auth via the
|
|
4
|
+
// `x-api-key` header plus `anthropic-version`. Thinking is adaptive — the
|
|
5
|
+
// `budget_tokens` form is rejected with a 400 on current models — and depth is
|
|
6
|
+
// steered by `output_config.effort` instead. `display: "summarized"` is set on
|
|
7
|
+
// purpose: the default omits the reasoning text, which on a screen reads as a
|
|
8
|
+
// long silence before anything appears.
|
|
9
|
+
import { postSse } from "./http.js";
|
|
10
|
+
import { listModels } from "./catalog.js";
|
|
11
|
+
import { keyFor } from "../credentials.js";
|
|
12
|
+
import { assembleAnthropic } from "./anthropic-stream.js";
|
|
13
|
+
import { fromWireResponse, stopNotice, toWireMessage, toWireTool } from "./anthropic-wire.js";
|
|
14
|
+
const ENDPOINT = "https://api.anthropic.com/v1/messages";
|
|
15
|
+
const MODELS = "https://api.anthropic.com/v1/models?limit=100";
|
|
16
|
+
const API_VERSION = "2023-06-01";
|
|
17
|
+
const KEY = "ANTHROPIC_API_KEY";
|
|
18
|
+
// Adaptive thinking and `output_config.effort` exist on the 4.6-and-later
|
|
19
|
+
// families only. Older models reject both with a 400, so `/model claude-haiku-4-5`
|
|
20
|
+
// would fail on send if the request shape were fixed. It follows the model.
|
|
21
|
+
const ADAPTIVE = /^claude-(fable-5|opus-(5|4-[678])|sonnet-(5|4-6))/;
|
|
22
|
+
export function supportsAdaptiveThinking(model) {
|
|
23
|
+
return ADAPTIVE.test(model);
|
|
24
|
+
}
|
|
25
|
+
export const anthropic = {
|
|
26
|
+
id: "anthropic",
|
|
27
|
+
// Sonnet is the default because it is the one that can be left running.
|
|
28
|
+
// Opus via `--model claude-opus-5`, Haiku via `--model claude-haiku-4-5`.
|
|
29
|
+
defaultModel: "claude-sonnet-5",
|
|
30
|
+
keyVar: KEY,
|
|
31
|
+
blocked() {
|
|
32
|
+
return apiKey() === undefined ? `${KEY} is not set` : undefined;
|
|
33
|
+
},
|
|
34
|
+
// Newest first is how the endpoint already answers, so the order is left
|
|
35
|
+
// exactly as it arrives rather than re-sorted into something less useful.
|
|
36
|
+
models(signal, onStatus) {
|
|
37
|
+
return listModels(MODELS, headers(requireKey()), signal, onStatus);
|
|
38
|
+
},
|
|
39
|
+
location: () => "cloud",
|
|
40
|
+
async send(req) {
|
|
41
|
+
const key = requireKey();
|
|
42
|
+
const body = {
|
|
43
|
+
model: req.model,
|
|
44
|
+
max_tokens: req.maxTokens,
|
|
45
|
+
system: req.system,
|
|
46
|
+
messages: req.messages.map(toWireMessage),
|
|
47
|
+
tools: req.tools.map(toWireTool),
|
|
48
|
+
stream: true,
|
|
49
|
+
};
|
|
50
|
+
if (supportsAdaptiveThinking(req.model)) {
|
|
51
|
+
body["thinking"] = { type: "adaptive", display: "summarized" };
|
|
52
|
+
body["output_config"] = { effort: req.effort };
|
|
53
|
+
}
|
|
54
|
+
const events = await postSse(ENDPOINT, headers(key), body, req.signal, req.onStatus);
|
|
55
|
+
const data = await assembleAnthropic(events, req.onStream);
|
|
56
|
+
// A refusal or a truncation never arrives as streamed text, so it has to
|
|
57
|
+
// be announced separately or the user watches the turn end in silence.
|
|
58
|
+
const notice = stopNotice(data);
|
|
59
|
+
if (notice !== undefined)
|
|
60
|
+
req.onStream?.({ kind: "text", text: `\n${notice}` });
|
|
61
|
+
return fromWireResponse(data);
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
// Read at the moment it is used, never captured at import: a key typed into
|
|
65
|
+
// the running window has to count, and so does one exported after startup.
|
|
66
|
+
function apiKey() {
|
|
67
|
+
return keyFor(KEY);
|
|
68
|
+
}
|
|
69
|
+
function requireKey() {
|
|
70
|
+
const key = apiKey();
|
|
71
|
+
if (key === undefined)
|
|
72
|
+
throw new Error(`${KEY} is not set`);
|
|
73
|
+
return key;
|
|
74
|
+
}
|
|
75
|
+
function headers(key) {
|
|
76
|
+
return { "x-api-key": key, "anthropic-version": API_VERSION };
|
|
77
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Asking a provider what it will answer to.
|
|
2
|
+
//
|
|
3
|
+
// Nothing here is a list of models. A catalogue written into this repo is
|
|
4
|
+
// wrong the week after it is written, and wrong in the worst way — it names
|
|
5
|
+
// models that no longer exist and hides the ones that do. So the question goes
|
|
6
|
+
// to the provider, every time the user opens the menu.
|
|
7
|
+
//
|
|
8
|
+
// All three happen to answer the same GET at `/v1/models` with the same shape,
|
|
9
|
+
// `{ data: [{ id }] }`, so the asking is written once and a provider supplies
|
|
10
|
+
// only its URL and its headers.
|
|
11
|
+
import { getJson } from "./http.js";
|
|
12
|
+
export const MAX_MODEL_CATALOG_ENTRIES = 1_000;
|
|
13
|
+
export const MAX_MODEL_CATALOG_ITEMS = 4_000;
|
|
14
|
+
export const MAX_MODEL_ID_CHARS = 256;
|
|
15
|
+
export async function listModels(url, headers, signal, onStatus) {
|
|
16
|
+
const body = await getJson(url, headers, signal, onStatus);
|
|
17
|
+
const data = body.data;
|
|
18
|
+
if (!Array.isArray(data))
|
|
19
|
+
throw new Error(`${url} did not return a model list`);
|
|
20
|
+
const models = [];
|
|
21
|
+
const unique = new Set();
|
|
22
|
+
const inspected = Math.min(data.length, MAX_MODEL_CATALOG_ITEMS);
|
|
23
|
+
for (let index = 0; index < inspected && models.length < MAX_MODEL_CATALOG_ENTRIES; index++) {
|
|
24
|
+
const entry = data[index];
|
|
25
|
+
const id = typeof entry === "object" && entry !== null
|
|
26
|
+
? entry.id
|
|
27
|
+
: undefined;
|
|
28
|
+
if (typeof id !== "string" ||
|
|
29
|
+
id === "" ||
|
|
30
|
+
id.length > MAX_MODEL_ID_CHARS ||
|
|
31
|
+
unique.has(id))
|
|
32
|
+
continue;
|
|
33
|
+
unique.add(id);
|
|
34
|
+
models.push(id);
|
|
35
|
+
}
|
|
36
|
+
return models;
|
|
37
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// The entire HTTP layer: one bounded request, then either a JSON body or an
|
|
2
|
+
// event stream. Only idempotent reads retry. Once a POST starts or response
|
|
3
|
+
// bytes flow, a failure is surfaced rather than silently replayed.
|
|
4
|
+
import { readSseJson } from "./sse.js";
|
|
5
|
+
const RETRYABLE = new Set([408, 409, 429, 500, 502, 503, 504]);
|
|
6
|
+
const MAX_JSON_CHARS = 5_000_000;
|
|
7
|
+
const MAX_ERROR_CHARS = 2_000;
|
|
8
|
+
const HANDSHAKE_TIMEOUT_MS = 60_000;
|
|
9
|
+
const BODY_IDLE_TIMEOUT_MS = 120_000;
|
|
10
|
+
const GET_RETRIES = 3;
|
|
11
|
+
function httpError(message, status, body) {
|
|
12
|
+
const error = new Error(message);
|
|
13
|
+
error.status = status;
|
|
14
|
+
error.body = body;
|
|
15
|
+
return error;
|
|
16
|
+
}
|
|
17
|
+
export async function postJson(url, headers, body, signal, onStatus) {
|
|
18
|
+
return asJson(url, await request(url, headers, body, signal, onStatus));
|
|
19
|
+
}
|
|
20
|
+
/** A plain read. The only thing jecode asks for without sending anything. */
|
|
21
|
+
export async function getJson(url, headers, signal, onStatus) {
|
|
22
|
+
return asJson(url, await request(url, headers, undefined, signal, onStatus));
|
|
23
|
+
}
|
|
24
|
+
async function asJson(url, res) {
|
|
25
|
+
const { text, truncated } = await boundedText(url, res, MAX_JSON_CHARS);
|
|
26
|
+
if (truncated) {
|
|
27
|
+
throw httpError(`${url} returned JSON over ${MAX_JSON_CHARS} characters`, res.status, text);
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(text);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw httpError(`${url} returned non-JSON`, res.status, text.slice(0, 500));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export async function postSse(url, headers, body, signal, onStatus) {
|
|
37
|
+
const res = await request(url, { accept: "text/event-stream", ...headers }, body, signal, onStatus);
|
|
38
|
+
if (res.body === null)
|
|
39
|
+
throw httpError(`${url} returned no body`, res.status);
|
|
40
|
+
return readSseJson(withIdleTimeout(url, res.body));
|
|
41
|
+
}
|
|
42
|
+
async function request(url, headers, body, signal, onStatus) {
|
|
43
|
+
const maxRetries = body === undefined ? GET_RETRIES : 0;
|
|
44
|
+
let lastError;
|
|
45
|
+
let waitMs = 0;
|
|
46
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
47
|
+
if (waitMs > 0)
|
|
48
|
+
await sleep(waitMs, signal);
|
|
49
|
+
let res;
|
|
50
|
+
const handshake = handshakeSignal(url, signal);
|
|
51
|
+
try {
|
|
52
|
+
// No body, no method: a request with nothing to send is a read, and
|
|
53
|
+
// saying so is what keeps the retry and error handling in one place.
|
|
54
|
+
res = await fetch(url, {
|
|
55
|
+
method: body === undefined ? "GET" : "POST",
|
|
56
|
+
headers: body === undefined ? headers : { "content-type": "application/json", ...headers },
|
|
57
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
58
|
+
redirect: "manual",
|
|
59
|
+
signal: handshake.signal,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
catch (cause) {
|
|
63
|
+
const timeout = handshake.timeout();
|
|
64
|
+
if (timeout !== undefined)
|
|
65
|
+
throw timeout;
|
|
66
|
+
if (signal?.aborted === true)
|
|
67
|
+
throw cause;
|
|
68
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
69
|
+
lastError = httpError(`network error calling ${url}: ${detail}`);
|
|
70
|
+
waitMs = backoff(attempt);
|
|
71
|
+
if (attempt < maxRetries)
|
|
72
|
+
onStatus?.(`Network error · retrying in ${waitLabel(waitMs)}`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
handshake.clear();
|
|
77
|
+
}
|
|
78
|
+
if (res.status >= 300 && res.status < 400) {
|
|
79
|
+
await res.body?.cancel().catch(() => undefined);
|
|
80
|
+
throw httpError(`${url} -> ${res.status} redirect rejected`, res.status);
|
|
81
|
+
}
|
|
82
|
+
if (res.ok)
|
|
83
|
+
return res;
|
|
84
|
+
const { text } = await boundedText(url, res, MAX_ERROR_CHARS);
|
|
85
|
+
lastError = httpError(`${url} -> ${res.status} ${res.statusText}`, res.status, text);
|
|
86
|
+
if (!RETRYABLE.has(res.status))
|
|
87
|
+
throw lastError;
|
|
88
|
+
waitMs = retryAfter(res) ?? backoff(attempt);
|
|
89
|
+
if (attempt < maxRetries) {
|
|
90
|
+
const reason = res.status === 429 ? "Rate limited" : `HTTP ${res.status}`;
|
|
91
|
+
onStatus?.(`${reason} · retrying in ${waitLabel(waitMs)}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
throw lastError ?? httpError(`${url} failed`);
|
|
95
|
+
}
|
|
96
|
+
async function boundedText(url, res, max) {
|
|
97
|
+
if (res.body === null)
|
|
98
|
+
return { text: "", truncated: false };
|
|
99
|
+
const reader = res.body.getReader();
|
|
100
|
+
const decoder = new TextDecoder();
|
|
101
|
+
let text = "";
|
|
102
|
+
try {
|
|
103
|
+
while (true) {
|
|
104
|
+
const { done, value } = await timedRead(url, reader);
|
|
105
|
+
if (done) {
|
|
106
|
+
text += decoder.decode();
|
|
107
|
+
return text.length > max
|
|
108
|
+
? { text: text.slice(0, max), truncated: true }
|
|
109
|
+
: { text, truncated: false };
|
|
110
|
+
}
|
|
111
|
+
text += decoder.decode(value, { stream: true });
|
|
112
|
+
if (text.length > max) {
|
|
113
|
+
await reader.cancel().catch(() => undefined);
|
|
114
|
+
return { text: text.slice(0, max), truncated: true };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
reader.releaseLock();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function handshakeSignal(url, signal) {
|
|
123
|
+
const controller = new AbortController();
|
|
124
|
+
let timeout;
|
|
125
|
+
const timer = setTimeout(() => {
|
|
126
|
+
timeout = httpError(`${url} timed out waiting for response headers after ${HANDSHAKE_TIMEOUT_MS}ms`);
|
|
127
|
+
controller.abort(timeout);
|
|
128
|
+
}, HANDSHAKE_TIMEOUT_MS);
|
|
129
|
+
return {
|
|
130
|
+
signal: signal === undefined ? controller.signal : AbortSignal.any([signal, controller.signal]),
|
|
131
|
+
timeout: () => timeout,
|
|
132
|
+
clear: () => clearTimeout(timer),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
async function timedRead(url, reader) {
|
|
136
|
+
const timeout = httpError(`${url} response body was idle for ${BODY_IDLE_TIMEOUT_MS}ms`);
|
|
137
|
+
let timer;
|
|
138
|
+
const expired = new Promise((_resolve, reject) => {
|
|
139
|
+
timer = setTimeout(() => reject(timeout), BODY_IDLE_TIMEOUT_MS);
|
|
140
|
+
});
|
|
141
|
+
try {
|
|
142
|
+
return await Promise.race([reader.read(), expired]);
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
if (error === timeout)
|
|
146
|
+
await reader.cancel(timeout).catch(() => undefined);
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
if (timer !== undefined)
|
|
151
|
+
clearTimeout(timer);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function withIdleTimeout(url, body) {
|
|
155
|
+
const reader = body.getReader();
|
|
156
|
+
let released = false;
|
|
157
|
+
const release = () => {
|
|
158
|
+
if (released)
|
|
159
|
+
return;
|
|
160
|
+
released = true;
|
|
161
|
+
reader.releaseLock();
|
|
162
|
+
};
|
|
163
|
+
return new ReadableStream({
|
|
164
|
+
async pull(controller) {
|
|
165
|
+
try {
|
|
166
|
+
const { done, value } = await timedRead(url, reader);
|
|
167
|
+
if (done) {
|
|
168
|
+
release();
|
|
169
|
+
controller.close();
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
controller.enqueue(value);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
await reader.cancel(error).catch(() => undefined);
|
|
177
|
+
release();
|
|
178
|
+
controller.error(error);
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
async cancel(reason) {
|
|
182
|
+
await reader.cancel(reason).catch(() => undefined);
|
|
183
|
+
release();
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
function waitLabel(ms) {
|
|
188
|
+
return ms < 1_000 ? `${ms}ms` : `${Math.ceil(ms / 1_000)}s`;
|
|
189
|
+
}
|
|
190
|
+
function backoff(attempt) {
|
|
191
|
+
return Math.min(30_000, 1_000 * 2 ** attempt);
|
|
192
|
+
}
|
|
193
|
+
function retryAfter(res) {
|
|
194
|
+
const header = res.headers.get("retry-after");
|
|
195
|
+
if (header === null)
|
|
196
|
+
return undefined;
|
|
197
|
+
const seconds = Number(header);
|
|
198
|
+
if (Number.isFinite(seconds) && seconds >= 0)
|
|
199
|
+
return Math.min(60_000, seconds * 1_000);
|
|
200
|
+
const at = Date.parse(header);
|
|
201
|
+
return Number.isNaN(at) ? undefined : Math.max(0, Math.min(60_000, at - Date.now()));
|
|
202
|
+
}
|
|
203
|
+
function sleep(ms, signal) {
|
|
204
|
+
return new Promise((resolve, reject) => {
|
|
205
|
+
if (signal?.aborted === true) {
|
|
206
|
+
reject(signal.reason);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const onAbort = () => {
|
|
210
|
+
clearTimeout(timer);
|
|
211
|
+
reject(signal?.reason);
|
|
212
|
+
};
|
|
213
|
+
const timer = setTimeout(() => {
|
|
214
|
+
signal?.removeEventListener("abort", onAbort);
|
|
215
|
+
resolve();
|
|
216
|
+
}, ms);
|
|
217
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
218
|
+
});
|
|
219
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { anthropic } from "./anthropic.js";
|
|
2
|
+
import { openai } from "./openai.js";
|
|
3
|
+
import { configureOllama, ollama } from "./ollama.js";
|
|
4
|
+
export const PROVIDERS = [anthropic, openai, ollama];
|
|
5
|
+
export function providerNames() {
|
|
6
|
+
return PROVIDERS.map((provider) => provider.id);
|
|
7
|
+
}
|
|
8
|
+
export function selectProvider(id) {
|
|
9
|
+
const found = PROVIDERS.find((provider) => provider.id === id);
|
|
10
|
+
if (found === undefined) {
|
|
11
|
+
throw new Error(`unknown provider "${id}" (available: ${providerNames().join(", ")})`);
|
|
12
|
+
}
|
|
13
|
+
return found;
|
|
14
|
+
}
|
|
15
|
+
/** Apply provider-specific process state after startup precedence is resolved. */
|
|
16
|
+
export function configureProviders(config) {
|
|
17
|
+
configureOllama(config.ollamaHost);
|
|
18
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Turning an Ollama host into one safe, normalized endpoint.
|
|
2
|
+
export const OLLAMA_LOCAL_HOST = "http://127.0.0.1:11434";
|
|
3
|
+
export const OLLAMA_CLOUD_HOST = "https://ollama.com";
|
|
4
|
+
export function parseOllamaEndpoint(value) {
|
|
5
|
+
let url;
|
|
6
|
+
try {
|
|
7
|
+
url = new URL(value.trim());
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
throw new Error("Ollama endpoint must be an absolute HTTP(S) URL");
|
|
11
|
+
}
|
|
12
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
13
|
+
throw new Error("Ollama endpoint must use HTTP or HTTPS");
|
|
14
|
+
}
|
|
15
|
+
if (url.username !== "" || url.password !== "") {
|
|
16
|
+
throw new Error("Ollama endpoint must not contain credentials");
|
|
17
|
+
}
|
|
18
|
+
if (url.search !== "" || url.hash !== "") {
|
|
19
|
+
throw new Error("Ollama endpoint must not contain a query or fragment");
|
|
20
|
+
}
|
|
21
|
+
const loopback = isExactLoopback(url.hostname);
|
|
22
|
+
if (url.protocol === "http:" && !loopback) {
|
|
23
|
+
throw new Error("Ollama endpoint must use HTTPS unless it is an exact loopback address");
|
|
24
|
+
}
|
|
25
|
+
const pathname = url.pathname.replace(/\/+$/, "");
|
|
26
|
+
return {
|
|
27
|
+
baseUrl: `${url.origin}${pathname === "" ? "" : pathname}`,
|
|
28
|
+
loopback,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export function ollamaConnectionKind(endpoint) {
|
|
32
|
+
if (endpoint.baseUrl === OLLAMA_CLOUD_HOST)
|
|
33
|
+
return "cloud";
|
|
34
|
+
if (endpoint.baseUrl === OLLAMA_LOCAL_HOST)
|
|
35
|
+
return "local";
|
|
36
|
+
return "custom";
|
|
37
|
+
}
|
|
38
|
+
function isExactLoopback(hostname) {
|
|
39
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
|
|
40
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Reassembling a Chat Completions reply from its event stream.
|
|
2
|
+
//
|
|
3
|
+
// Nothing arrives finished here: text accumulates, and each tool call is
|
|
4
|
+
// spread across chunks keyed by `index` — id and name usually in the first,
|
|
5
|
+
// arguments as JSON fragments after it.
|
|
6
|
+
import { addBounded, MAX_TOOL_ARGUMENT_CHARS } from "./stream-limits.js";
|
|
7
|
+
export async function assembleOllama(events, onStream) {
|
|
8
|
+
const calls = new Map();
|
|
9
|
+
let toolArgumentChars = 0;
|
|
10
|
+
let content = "";
|
|
11
|
+
let finishReason;
|
|
12
|
+
let usage;
|
|
13
|
+
for await (const raw of events) {
|
|
14
|
+
const event = raw;
|
|
15
|
+
if (event.error !== undefined) {
|
|
16
|
+
const message = typeof event.error === "string" ? event.error : event.error.message;
|
|
17
|
+
throw new Error(`ollama stream error: ${message ?? "unspecified"}`);
|
|
18
|
+
}
|
|
19
|
+
if (event.usage !== undefined)
|
|
20
|
+
usage = event.usage;
|
|
21
|
+
const choice = event.choices?.[0];
|
|
22
|
+
if (choice === undefined)
|
|
23
|
+
continue;
|
|
24
|
+
if (typeof choice.finish_reason === "string")
|
|
25
|
+
finishReason = choice.finish_reason;
|
|
26
|
+
const delta = choice.delta;
|
|
27
|
+
if (delta === undefined)
|
|
28
|
+
continue;
|
|
29
|
+
// Reasoning has no standardized field name across the models Ollama
|
|
30
|
+
// serves, so both spellings in circulation are accepted.
|
|
31
|
+
const reasoning = delta.reasoning ?? delta.reasoning_content;
|
|
32
|
+
if (typeof reasoning === "string" && reasoning !== "") {
|
|
33
|
+
onStream?.({ kind: "thinking", text: reasoning });
|
|
34
|
+
}
|
|
35
|
+
if (typeof delta.content === "string" && delta.content !== "") {
|
|
36
|
+
content += delta.content;
|
|
37
|
+
onStream?.({ kind: "text", text: delta.content });
|
|
38
|
+
}
|
|
39
|
+
for (const part of delta.tool_calls ?? []) {
|
|
40
|
+
const index = typeof part.index === "number" ? part.index : 0;
|
|
41
|
+
const call = calls.get(index) ?? { id: "", name: "", args: "" };
|
|
42
|
+
if (typeof part.id === "string" && part.id !== "")
|
|
43
|
+
call.id = part.id;
|
|
44
|
+
if (typeof part.function?.name === "string" && part.function.name !== "") {
|
|
45
|
+
call.name = part.function.name;
|
|
46
|
+
}
|
|
47
|
+
if (typeof part.function?.arguments === "string") {
|
|
48
|
+
toolArgumentChars = addBounded(toolArgumentChars, part.function.arguments.length, MAX_TOOL_ARGUMENT_CHARS, "streamed tool arguments");
|
|
49
|
+
call.args += part.function.arguments;
|
|
50
|
+
}
|
|
51
|
+
calls.set(index, call);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const toolCalls = [...calls.entries()]
|
|
55
|
+
.sort(([a], [b]) => a - b)
|
|
56
|
+
// Not every server sends an id, and the loop needs one to pair the result
|
|
57
|
+
// back to its call.
|
|
58
|
+
.map(([index, call]) => (call.id === "" ? { ...call, id: `call_${index}` } : call));
|
|
59
|
+
return { content, toolCalls, finishReason, usage };
|
|
60
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Translation between the normalized vocabulary and the OpenAI-compatible
|
|
2
|
+
// Chat Completions shape Ollama serves at /v1/chat/completions.
|
|
3
|
+
//
|
|
4
|
+
// This is a third wire format rather than a reuse of the OpenAI provider, and
|
|
5
|
+
// the reason is worth stating: Ollama does not implement the Responses API.
|
|
6
|
+
// Chat Completions differs on two points that matter here — a tool result is a
|
|
7
|
+
// message of its own with role "tool", not a block inside a user turn, and tool
|
|
8
|
+
// arguments travel as a JSON string rather than an object.
|
|
9
|
+
export function toWireTool(tool) {
|
|
10
|
+
return {
|
|
11
|
+
type: "function",
|
|
12
|
+
function: { name: tool.name, description: tool.description, parameters: tool.input },
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
// Flattens the history: one normalized message can become several wire
|
|
16
|
+
// messages, because every tool result is its own turn.
|
|
17
|
+
export function toWireMessages(system, messages) {
|
|
18
|
+
const wire = [];
|
|
19
|
+
if (system !== "")
|
|
20
|
+
wire.push({ role: "system", content: system });
|
|
21
|
+
for (const message of messages) {
|
|
22
|
+
const texts = [];
|
|
23
|
+
const toolCalls = [];
|
|
24
|
+
for (const block of message.content) {
|
|
25
|
+
if (block.kind === "text") {
|
|
26
|
+
texts.push(block.text);
|
|
27
|
+
}
|
|
28
|
+
else if (block.kind === "tool_call") {
|
|
29
|
+
toolCalls.push({
|
|
30
|
+
id: block.id,
|
|
31
|
+
type: "function",
|
|
32
|
+
function: { name: block.name, arguments: JSON.stringify(block.input) },
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
wire.push({ role: "tool", tool_call_id: block.id, content: block.output });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (message.role === "assistant") {
|
|
40
|
+
if (texts.length === 0 && toolCalls.length === 0)
|
|
41
|
+
continue;
|
|
42
|
+
const turn = { role: "assistant", content: texts.join("\n") };
|
|
43
|
+
// An empty tool_calls array is not the same as no tool_calls to every
|
|
44
|
+
// server, so the key is omitted rather than sent empty.
|
|
45
|
+
if (toolCalls.length > 0)
|
|
46
|
+
turn["tool_calls"] = toolCalls;
|
|
47
|
+
wire.push(turn);
|
|
48
|
+
}
|
|
49
|
+
else if (texts.length > 0) {
|
|
50
|
+
wire.push({ role: "user", content: texts.join("\n") });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return wire;
|
|
54
|
+
}
|
|
55
|
+
export function fromWireReply(reply) {
|
|
56
|
+
const content = [];
|
|
57
|
+
if (reply.content !== "")
|
|
58
|
+
content.push({ kind: "text", text: reply.content });
|
|
59
|
+
for (const call of reply.toolCalls) {
|
|
60
|
+
content.push({ kind: "tool_call", id: call.id, name: call.name, input: parseArgs(call.args) });
|
|
61
|
+
}
|
|
62
|
+
// No `raw`: unlike the other two providers, nothing in this shape has to be
|
|
63
|
+
// echoed back verbatim, so the normalized blocks are the whole message.
|
|
64
|
+
return { role: "assistant", content, usage: normalizeUsage(reply) };
|
|
65
|
+
}
|
|
66
|
+
function normalizeUsage(reply) {
|
|
67
|
+
if (reply.usage === undefined)
|
|
68
|
+
return undefined;
|
|
69
|
+
return {
|
|
70
|
+
inputTokens: reply.usage.prompt_tokens ?? 0,
|
|
71
|
+
outputTokens: reply.usage.completion_tokens ?? 0,
|
|
72
|
+
cachedInputTokens: 0,
|
|
73
|
+
cacheWriteInputTokens: 0,
|
|
74
|
+
reasoningTokens: 0,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export function stopNotice(reply) {
|
|
78
|
+
return reply.finishReason === "length"
|
|
79
|
+
? "[truncated: hit the output limit — raise --max-tokens]"
|
|
80
|
+
: undefined;
|
|
81
|
+
}
|
|
82
|
+
// A model that emits malformed JSON gets the empty object, which fails
|
|
83
|
+
// validation in tools/args.ts with a message written for it to read. That is a
|
|
84
|
+
// recoverable turn; throwing here would end the whole thing instead.
|
|
85
|
+
function parseArgs(args) {
|
|
86
|
+
if (args.trim() === "")
|
|
87
|
+
return {};
|
|
88
|
+
try {
|
|
89
|
+
const parsed = JSON.parse(args);
|
|
90
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return {};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Ollama, spoken through its OpenAI-compatible Chat Completions endpoint.
|
|
2
|
+
//
|
|
3
|
+
// One provider covers both deployments. An explicit session endpoint wins;
|
|
4
|
+
// otherwise a configured key selects Ollama Cloud and no key selects the local
|
|
5
|
+
// daemon. There is no default model — the catalogue is whatever the host has
|
|
6
|
+
// pulled or the subscription grants — so the model has to be named with
|
|
7
|
+
// --model.
|
|
8
|
+
import { postSse } from "./http.js";
|
|
9
|
+
import { listModels } from "./catalog.js";
|
|
10
|
+
import { keyFor } from "../credentials.js";
|
|
11
|
+
import { assembleOllama } from "./ollama-stream.js";
|
|
12
|
+
import { OLLAMA_CLOUD_HOST, OLLAMA_LOCAL_HOST, ollamaConnectionKind, parseOllamaEndpoint, } from "./ollama-endpoint.js";
|
|
13
|
+
import { fromWireReply, stopNotice, toWireMessages, toWireTool } from "./ollama-wire.js";
|
|
14
|
+
const KEY = "OLLAMA_API_KEY";
|
|
15
|
+
let configuredHost;
|
|
16
|
+
/** Set the endpoint selected for this process. Undefined restores key-aware inference. */
|
|
17
|
+
export function configureOllama(host) {
|
|
18
|
+
configuredHost = host === undefined ? undefined : parseOllamaEndpoint(host).baseUrl;
|
|
19
|
+
}
|
|
20
|
+
export function ollamaConnection() {
|
|
21
|
+
const inferred = configuredHost === undefined;
|
|
22
|
+
const endpoint = parseOllamaEndpoint(configuredHost ?? (apiKey() === undefined ? OLLAMA_LOCAL_HOST : OLLAMA_CLOUD_HOST));
|
|
23
|
+
return { ...endpoint, kind: ollamaConnectionKind(endpoint), inferred };
|
|
24
|
+
}
|
|
25
|
+
export const ollama = {
|
|
26
|
+
id: "ollama",
|
|
27
|
+
defaultModel: "",
|
|
28
|
+
keyVar: KEY,
|
|
29
|
+
// The only provider whose key is conditional: a daemon on this machine is
|
|
30
|
+
// reached over loopback and asks for nothing, so demanding a key there
|
|
31
|
+
// would be an invented requirement.
|
|
32
|
+
blocked() {
|
|
33
|
+
try {
|
|
34
|
+
const at = endpoint();
|
|
35
|
+
if (apiKey() !== undefined || at.loopback)
|
|
36
|
+
return undefined;
|
|
37
|
+
return `${KEY} is not set (required by ${at.baseUrl})`;
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
return error.message;
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
// Whatever the daemon has pulled, or whatever the subscription grants.
|
|
44
|
+
models(signal, onStatus) {
|
|
45
|
+
const at = endpoint();
|
|
46
|
+
return listModels(`${at.baseUrl}/v1/models`, headers(at), signal, onStatus);
|
|
47
|
+
},
|
|
48
|
+
location: () => {
|
|
49
|
+
try {
|
|
50
|
+
return endpoint().loopback ? "local" : "cloud";
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return "cloud";
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
async send(req) {
|
|
57
|
+
const at = endpoint();
|
|
58
|
+
// `effort` has no equivalent in this shape and is dropped rather than
|
|
59
|
+
// guessed at — depth on these models is a property of the model chosen.
|
|
60
|
+
const events = await postSse(`${at.baseUrl}/v1/chat/completions`, headers(at), {
|
|
61
|
+
model: req.model,
|
|
62
|
+
messages: toWireMessages(req.system, req.messages),
|
|
63
|
+
tools: req.tools.map(toWireTool),
|
|
64
|
+
max_tokens: req.maxTokens,
|
|
65
|
+
stream: true,
|
|
66
|
+
}, req.signal, req.onStatus);
|
|
67
|
+
const reply = await assembleOllama(events, req.onStream);
|
|
68
|
+
const notice = stopNotice(reply);
|
|
69
|
+
if (notice !== undefined)
|
|
70
|
+
req.onStream?.({ kind: "text", text: `\n${notice}` });
|
|
71
|
+
return fromWireReply(reply);
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
function endpoint() {
|
|
75
|
+
return ollamaConnection();
|
|
76
|
+
}
|
|
77
|
+
function apiKey() {
|
|
78
|
+
return keyFor(KEY);
|
|
79
|
+
}
|
|
80
|
+
function headers(at) {
|
|
81
|
+
if (at.loopback)
|
|
82
|
+
return {};
|
|
83
|
+
const key = apiKey();
|
|
84
|
+
if (key !== undefined)
|
|
85
|
+
return { authorization: `Bearer ${key}` };
|
|
86
|
+
throw new Error(`${KEY} is not set (required by ${at.baseUrl})`);
|
|
87
|
+
}
|