@giovannijecha/jecode 0.4.0 → 0.6.0
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 +59 -9
- package/dist/batch.js +73 -3
- package/dist/cli-info.js +6 -0
- package/dist/commands.js +3 -2
- package/dist/config.js +14 -0
- package/dist/context/capacity.js +18 -0
- package/dist/context/compactor.js +60 -0
- package/dist/context/policy.js +125 -0
- package/dist/context/projection.js +49 -0
- package/dist/controller-request.js +34 -0
- package/dist/controller.js +25 -15
- package/dist/conversation.js +236 -0
- package/dist/launch.js +19 -0
- package/dist/providers/anthropic.js +33 -3
- package/dist/providers/catalog.js +11 -4
- package/dist/providers/ollama.js +83 -4
- package/dist/providers/openai-codex.js +46 -2
- package/dist/providers/openai.js +17 -0
- package/dist/sessions/codec.js +376 -0
- package/dist/sessions/lease.js +76 -0
- package/dist/sessions/runtime.js +73 -0
- package/dist/sessions/store.js +368 -0
- package/dist/settings-command.js +42 -2
- package/dist/settings.js +9 -0
- package/dist/start.js +67 -4
- package/dist/transcript-types.js +6 -0
- package/dist/tui/activity.js +3 -0
- package/dist/tui/app-input.js +9 -6
- package/dist/tui/app-workflows.js +99 -4
- package/dist/tui/app.js +71 -19
- package/dist/tui/components/messages.js +0 -14
- package/dist/tui/components/status.js +4 -1
- package/dist/tui/components/tool.js +19 -2
- package/dist/tui/feedback.js +3 -0
- package/dist/tui/resume.js +24 -0
- package/dist/tui/turn.js +2 -3
- package/dist/ui/diff.js +2 -0
- package/dist/ui/render.js +2 -0
- package/dist/usage.js +17 -0
- package/package.json +1 -1
package/dist/controller.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// loop iterates. That constraint is implemented here, not left as a product claim.
|
|
6
6
|
import { isToolCall } from "./types.js";
|
|
7
7
|
import { findTool, runTool, toolSpecs } from "./tools/index.js";
|
|
8
|
+
import { requestAssistant } from "./controller-request.js";
|
|
8
9
|
export const MAX_TOOL_CALLS_PER_STEP = 32;
|
|
9
10
|
/** Independent read calls share one bounded execution wave. */
|
|
10
11
|
export const MAX_CONCURRENT_TOOL_CALLS = 4;
|
|
@@ -13,24 +14,27 @@ export const MAX_CONCURRENT_TOOL_CALLS = 4;
|
|
|
13
14
|
* stops asking for tools. `history` is mutated in place, so an aborted turn
|
|
14
15
|
* still leaves the conversation in a consistent state.
|
|
15
16
|
*/
|
|
16
|
-
export async function runTurn(history, options, events, signal) {
|
|
17
|
+
export async function runTurn(history, options, events, signal, modelHistory = history) {
|
|
17
18
|
const specs = toolSpecs(options.tools);
|
|
19
|
+
let context = modelHistory;
|
|
20
|
+
const append = (message) => {
|
|
21
|
+
history.push(message);
|
|
22
|
+
if (context !== history)
|
|
23
|
+
context.push(message);
|
|
24
|
+
};
|
|
25
|
+
const checkpoint = async (settlement) => {
|
|
26
|
+
const projected = await events.onCheckpoint?.(history, settlement, context);
|
|
27
|
+
if (projected !== undefined)
|
|
28
|
+
context = clone([...projected]);
|
|
29
|
+
};
|
|
18
30
|
for (let step = 0; step < options.maxSteps; step++) {
|
|
19
31
|
throwIfAborted(signal);
|
|
20
32
|
events.onStep?.(step + 1, options.maxSteps);
|
|
21
33
|
// The message is displayed as it streams; what comes back here is the
|
|
22
34
|
// assembled version, which exists to be appended to the history.
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
messages: history,
|
|
27
|
-
tools: specs,
|
|
28
|
-
maxTokens: options.maxTokens,
|
|
29
|
-
effort: options.effort,
|
|
30
|
-
signal,
|
|
31
|
-
onStream: (event) => events.onStream(event),
|
|
32
|
-
onStatus: (status) => events.onStatus?.(status),
|
|
33
|
-
});
|
|
35
|
+
const response = await requestAssistant(history, context, specs, options, events, signal);
|
|
36
|
+
const assistant = response.message;
|
|
37
|
+
context = response.context;
|
|
34
38
|
throwIfAborted(signal);
|
|
35
39
|
const calls = assistant.content.filter(isToolCall);
|
|
36
40
|
if (assistant.content.length === 0) {
|
|
@@ -40,10 +44,11 @@ export async function runTurn(history, options, events, signal) {
|
|
|
40
44
|
throw new Error(`provider returned ${calls.length} tool calls in one step (maximum ${MAX_TOOL_CALLS_PER_STEP})`);
|
|
41
45
|
}
|
|
42
46
|
assertToolCallIds(calls);
|
|
43
|
-
|
|
47
|
+
append(assistant);
|
|
44
48
|
if (calls.length === 0) {
|
|
45
49
|
if (assistant.usage !== undefined)
|
|
46
50
|
events.onUsage?.(assistant.usage);
|
|
51
|
+
await checkpoint("completed");
|
|
47
52
|
return; // the model is done — hand back to the user
|
|
48
53
|
}
|
|
49
54
|
// Consecutive shared reads run together. An exclusive call is an ordered
|
|
@@ -87,7 +92,7 @@ export async function runTurn(history, options, events, signal) {
|
|
|
87
92
|
repairs.push({ call, run });
|
|
88
93
|
results.push(run.result);
|
|
89
94
|
}
|
|
90
|
-
|
|
95
|
+
append({ role: "user", content: results });
|
|
91
96
|
// History repair is the invariant. UI recovery is best-effort and must
|
|
92
97
|
// never replace the original exception or leave the conversation open.
|
|
93
98
|
for (const { call, run } of repairs) {
|
|
@@ -100,11 +105,13 @@ export async function runTurn(history, options, events, signal) {
|
|
|
100
105
|
// The surface is already failing; the next turn can still proceed.
|
|
101
106
|
}
|
|
102
107
|
}
|
|
108
|
+
await checkpoint("checkpointed");
|
|
103
109
|
if (interrupted)
|
|
104
110
|
throw abortReason(signal);
|
|
105
111
|
throw error;
|
|
106
112
|
}
|
|
107
|
-
|
|
113
|
+
append({ role: "user", content: results });
|
|
114
|
+
await checkpoint("checkpointed");
|
|
108
115
|
}
|
|
109
116
|
throw new Error(`gave up after ${options.maxSteps} steps without finishing (raise --max-steps)`);
|
|
110
117
|
}
|
|
@@ -187,3 +194,6 @@ function throwIfAborted(signal) {
|
|
|
187
194
|
function abortReason(signal) {
|
|
188
195
|
return signal.reason instanceof Error ? signal.reason : new Error("interrupted");
|
|
189
196
|
}
|
|
197
|
+
function clone(value) {
|
|
198
|
+
return structuredClone(value);
|
|
199
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Canonical settled conversation state.
|
|
2
|
+
//
|
|
3
|
+
// A node owns one complete user-turn delta. The selected root-to-node path is
|
|
4
|
+
// the durable source for the transcript and full history; context anchors can
|
|
5
|
+
// project an older prefix before it is sent to a provider. Provider traffic
|
|
6
|
+
// and live screen blocks remain prospective until a consistent checkpoint.
|
|
7
|
+
import { projectContext, validContextAnchor } from "./context/projection.js";
|
|
8
|
+
export const CONVERSATION_LIMITS = Object.freeze({
|
|
9
|
+
nodes: 1_024,
|
|
10
|
+
messageCodeUnits: 8_388_608,
|
|
11
|
+
transcriptCodeUnits: 8_388_608,
|
|
12
|
+
contextCodeUnits: 8_388_608,
|
|
13
|
+
});
|
|
14
|
+
/** Immutable tree with one selected model/transcript path. */
|
|
15
|
+
export class ConversationTree {
|
|
16
|
+
#nodes;
|
|
17
|
+
#activeNodeId;
|
|
18
|
+
constructor(nodes, activeNodeId) {
|
|
19
|
+
this.#nodes = Object.freeze([...nodes]);
|
|
20
|
+
this.#activeNodeId = activeNodeId;
|
|
21
|
+
Object.freeze(this);
|
|
22
|
+
}
|
|
23
|
+
static empty() {
|
|
24
|
+
return new ConversationTree([], 0);
|
|
25
|
+
}
|
|
26
|
+
static restore(nodes, activeNodeId) {
|
|
27
|
+
let tree = ConversationTree.empty();
|
|
28
|
+
for (let index = 0; index < nodes.length; index++) {
|
|
29
|
+
const node = nodes[index];
|
|
30
|
+
if (node === undefined || node.id !== index + 1) {
|
|
31
|
+
throw new Error("session contains a non-sequential conversation node");
|
|
32
|
+
}
|
|
33
|
+
tree = tree.select(node.parentId).commit({
|
|
34
|
+
parentId: node.parentId,
|
|
35
|
+
createdAt: node.createdAt,
|
|
36
|
+
identity: node.identity,
|
|
37
|
+
messages: node.messages,
|
|
38
|
+
blocks: node.blocks,
|
|
39
|
+
...(node.context === undefined ? {} : { context: node.context }),
|
|
40
|
+
}, node.settlement);
|
|
41
|
+
const restored = tree.activeNode;
|
|
42
|
+
if (restored === undefined || restored.id !== node.id) {
|
|
43
|
+
throw new Error("session conversation could not be restored");
|
|
44
|
+
}
|
|
45
|
+
if (node.revision > 1) {
|
|
46
|
+
const copy = [...tree.#nodes];
|
|
47
|
+
copy[node.id - 1] = ownedNode({ ...restored, revision: node.revision });
|
|
48
|
+
tree = new ConversationTree(copy, node.id);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return tree.select(activeNodeId);
|
|
52
|
+
}
|
|
53
|
+
/** Commit or extend the one prospective leaf turn. */
|
|
54
|
+
commit(draft, settlement) {
|
|
55
|
+
if (draft.parentId !== (draft.nodeId === undefined
|
|
56
|
+
? this.#activeNodeId
|
|
57
|
+
: this.node(draft.nodeId)?.parentId)) {
|
|
58
|
+
throw new Error("turn parent no longer matches the selected conversation");
|
|
59
|
+
}
|
|
60
|
+
if (draft.nodeId === undefined)
|
|
61
|
+
return this.#append(draft, settlement);
|
|
62
|
+
return this.#replace(draft, settlement);
|
|
63
|
+
}
|
|
64
|
+
select(nodeId) {
|
|
65
|
+
if (!validNodeId(nodeId) || (nodeId !== 0 && this.#nodes[nodeId - 1]?.id !== nodeId)) {
|
|
66
|
+
throw new Error("conversation node does not exist");
|
|
67
|
+
}
|
|
68
|
+
return new ConversationTree(this.#nodes, nodeId);
|
|
69
|
+
}
|
|
70
|
+
node(nodeId) {
|
|
71
|
+
return nodeId === 0 ? undefined : this.#nodes[nodeId - 1];
|
|
72
|
+
}
|
|
73
|
+
get nodes() {
|
|
74
|
+
return this.#nodes;
|
|
75
|
+
}
|
|
76
|
+
get activeNodeId() {
|
|
77
|
+
return this.#activeNodeId;
|
|
78
|
+
}
|
|
79
|
+
get activeNode() {
|
|
80
|
+
return this.node(this.#activeNodeId);
|
|
81
|
+
}
|
|
82
|
+
/** Select the newest completed turn on the active path, if one exists. */
|
|
83
|
+
latestCompleted() {
|
|
84
|
+
let id = this.#activeNodeId;
|
|
85
|
+
while (id !== 0) {
|
|
86
|
+
const node = this.node(id);
|
|
87
|
+
if (node === undefined)
|
|
88
|
+
throw new Error("conversation path is incomplete");
|
|
89
|
+
if (node.settlement === "completed")
|
|
90
|
+
return this.select(id);
|
|
91
|
+
id = node.parentId;
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
get history() {
|
|
96
|
+
return this.#path().flatMap((node) => clone(node.messages));
|
|
97
|
+
}
|
|
98
|
+
get contextHistory() {
|
|
99
|
+
return projectContext(this.#path());
|
|
100
|
+
}
|
|
101
|
+
get transcript() {
|
|
102
|
+
return this.#path().flatMap((node) => clone(node.blocks));
|
|
103
|
+
}
|
|
104
|
+
#append(draft, settlement) {
|
|
105
|
+
if (this.#nodes.length >= CONVERSATION_LIMITS.nodes) {
|
|
106
|
+
throw new Error("conversation reached its session limit — start /new");
|
|
107
|
+
}
|
|
108
|
+
const node = ownedNode({
|
|
109
|
+
id: this.#nodes.length + 1,
|
|
110
|
+
parentId: draft.parentId,
|
|
111
|
+
revision: 1,
|
|
112
|
+
createdAt: draft.createdAt,
|
|
113
|
+
settlement,
|
|
114
|
+
identity: draft.identity,
|
|
115
|
+
messages: draft.messages,
|
|
116
|
+
blocks: settledBlocks(draft.blocks),
|
|
117
|
+
...(draft.context === undefined ? {} : { context: draft.context }),
|
|
118
|
+
});
|
|
119
|
+
assertTurn(node);
|
|
120
|
+
const nodes = [...this.#nodes, node];
|
|
121
|
+
assertBounds(nodes);
|
|
122
|
+
return new ConversationTree(nodes, node.id);
|
|
123
|
+
}
|
|
124
|
+
#replace(draft, settlement) {
|
|
125
|
+
const id = draft.nodeId;
|
|
126
|
+
const current = this.node(id);
|
|
127
|
+
if (current === undefined || id !== this.#activeNodeId || this.#nodes.some((node) => node.parentId === id)) {
|
|
128
|
+
throw new Error("only the active leaf turn can be checkpointed");
|
|
129
|
+
}
|
|
130
|
+
const node = ownedNode({
|
|
131
|
+
...current,
|
|
132
|
+
revision: current.revision + 1,
|
|
133
|
+
settlement,
|
|
134
|
+
identity: draft.identity,
|
|
135
|
+
messages: draft.messages,
|
|
136
|
+
blocks: settledBlocks(draft.blocks),
|
|
137
|
+
context: draft.context ?? current.context,
|
|
138
|
+
});
|
|
139
|
+
assertTurn(node);
|
|
140
|
+
const nodes = [...this.#nodes];
|
|
141
|
+
nodes[id - 1] = node;
|
|
142
|
+
assertBounds(nodes);
|
|
143
|
+
return new ConversationTree(nodes, id);
|
|
144
|
+
}
|
|
145
|
+
#path() {
|
|
146
|
+
const path = [];
|
|
147
|
+
let id = this.#activeNodeId;
|
|
148
|
+
while (id !== 0) {
|
|
149
|
+
const node = this.node(id);
|
|
150
|
+
if (node === undefined)
|
|
151
|
+
throw new Error("conversation path is incomplete");
|
|
152
|
+
path.push(node);
|
|
153
|
+
id = node.parentId;
|
|
154
|
+
}
|
|
155
|
+
path.reverse();
|
|
156
|
+
return path;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function ownedNode(node) {
|
|
160
|
+
return Object.freeze({
|
|
161
|
+
...node,
|
|
162
|
+
identity: Object.freeze({ ...node.identity }),
|
|
163
|
+
messages: Object.freeze(clone(node.messages)),
|
|
164
|
+
blocks: Object.freeze(clone(node.blocks)),
|
|
165
|
+
...(node.context === undefined ? {} : { context: Object.freeze({ ...node.context }) }),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
function settledBlocks(blocks) {
|
|
169
|
+
return clone(blocks).flatMap((block) => {
|
|
170
|
+
if (block.kind === "notice")
|
|
171
|
+
return [];
|
|
172
|
+
if (block.kind === "reasoning") {
|
|
173
|
+
const { live: _live, expanded: _expanded, ...settled } = block;
|
|
174
|
+
return [settled];
|
|
175
|
+
}
|
|
176
|
+
if (block.kind === "tool") {
|
|
177
|
+
const { startedAt: _startedAt, expanded: _expanded, ...settled } = block;
|
|
178
|
+
return [settled];
|
|
179
|
+
}
|
|
180
|
+
return [block];
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
function assertTurn(node) {
|
|
184
|
+
if (!validNodeId(node.id) || node.id === 0 ||
|
|
185
|
+
!validNodeId(node.parentId) || node.parentId >= node.id ||
|
|
186
|
+
!Number.isSafeInteger(node.revision) || node.revision < 1 ||
|
|
187
|
+
node.createdAt.length === 0 || node.createdAt.length > 64 ||
|
|
188
|
+
(node.settlement !== "checkpointed" && node.settlement !== "completed") ||
|
|
189
|
+
node.messages.length < 2 || node.messages[0]?.role !== "user" ||
|
|
190
|
+
node.identity.providerId.length === 0 || node.identity.providerId.length > 128 ||
|
|
191
|
+
node.identity.model.length === 0 || node.identity.model.length > 512 ||
|
|
192
|
+
node.identity.effort.length === 0 || node.identity.effort.length > 32)
|
|
193
|
+
throw new Error("turn checkpoint is invalid");
|
|
194
|
+
if (node.settlement === "completed" && node.messages.at(-1)?.role !== "assistant") {
|
|
195
|
+
throw new Error("a completed turn must end with an assistant message");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function assertBounds(nodes) {
|
|
199
|
+
let messageCodeUnits = 0;
|
|
200
|
+
let transcriptCodeUnits = 0;
|
|
201
|
+
let contextCodeUnits = 0;
|
|
202
|
+
for (const node of nodes) {
|
|
203
|
+
messageCodeUnits += JSON.stringify(node.messages).length;
|
|
204
|
+
transcriptCodeUnits += JSON.stringify(node.blocks).length;
|
|
205
|
+
contextCodeUnits += node.context?.summary.length ?? 0;
|
|
206
|
+
if (node.context !== undefined)
|
|
207
|
+
assertContextPath(nodes, node);
|
|
208
|
+
}
|
|
209
|
+
if (messageCodeUnits > CONVERSATION_LIMITS.messageCodeUnits) {
|
|
210
|
+
throw new Error("conversation model history reached its session limit — start /new");
|
|
211
|
+
}
|
|
212
|
+
if (transcriptCodeUnits > CONVERSATION_LIMITS.transcriptCodeUnits) {
|
|
213
|
+
throw new Error("conversation transcript reached its session limit — start /new");
|
|
214
|
+
}
|
|
215
|
+
if (contextCodeUnits > CONVERSATION_LIMITS.contextCodeUnits) {
|
|
216
|
+
throw new Error("conversation context summaries reached their session limit — start /new");
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function assertContextPath(nodes, owner) {
|
|
220
|
+
const context = owner.context;
|
|
221
|
+
const boundary = nodes[context.throughNodeId - 1];
|
|
222
|
+
if (boundary === undefined || !validContextAnchor(context, boundary.messages.length)) {
|
|
223
|
+
throw new Error("turn context checkpoint is invalid");
|
|
224
|
+
}
|
|
225
|
+
let id = owner.id;
|
|
226
|
+
while (id !== 0 && id !== boundary.id)
|
|
227
|
+
id = nodes[id - 1]?.parentId ?? 0;
|
|
228
|
+
if (id !== boundary.id)
|
|
229
|
+
throw new Error("turn context checkpoint is outside its branch");
|
|
230
|
+
}
|
|
231
|
+
function validNodeId(value) {
|
|
232
|
+
return Number.isSafeInteger(value) && value >= 0;
|
|
233
|
+
}
|
|
234
|
+
function clone(value) {
|
|
235
|
+
return structuredClone(value);
|
|
236
|
+
}
|
package/dist/launch.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function parseLaunch(argv) {
|
|
2
|
+
const first = argv[0];
|
|
3
|
+
if (first !== undefined && !first.startsWith("--") && first !== "-h" && first !== "-v") {
|
|
4
|
+
if (first !== "resume")
|
|
5
|
+
throw new Error(`unknown command ${first}`);
|
|
6
|
+
const rest = argv.slice(1);
|
|
7
|
+
const latest = rest.filter((value) => value === "--latest").length;
|
|
8
|
+
if (latest > 1)
|
|
9
|
+
throw new Error("--latest may be passed only once");
|
|
10
|
+
return {
|
|
11
|
+
kind: "resume",
|
|
12
|
+
latest: latest === 1,
|
|
13
|
+
configArgs: rest.filter((value) => value !== "--latest"),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
if (argv.includes("--latest"))
|
|
17
|
+
throw new Error("--latest requires `jecode resume`");
|
|
18
|
+
return { kind: "new", latest: false, configArgs: [...argv] };
|
|
19
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// purpose: the default omits the reasoning text, which on a screen reads as a
|
|
8
8
|
// long silence before anything appears.
|
|
9
9
|
import { postSse } from "./http.js";
|
|
10
|
-
import {
|
|
10
|
+
import { modelCatalog } from "./catalog.js";
|
|
11
11
|
import { keyFor } from "../credentials.js";
|
|
12
12
|
import { EFFORTS, requireSupportedEffort } from "../effort.js";
|
|
13
13
|
import { assembleAnthropic } from "./anthropic-stream.js";
|
|
@@ -19,6 +19,7 @@ const KEY = "ANTHROPIC_API_KEY";
|
|
|
19
19
|
const ADAPTIVE = /^claude-(?:fable-5|mythos-(?:5|preview)|opus-(?:5|4-[678])|sonnet-(?:5|4-6))(?:-|$)/;
|
|
20
20
|
const MAX_WITHOUT_XHIGH = ["low", "medium", "high", "max"];
|
|
21
21
|
const ANTHROPIC_45_EFFORTS = ["low", "medium", "high"];
|
|
22
|
+
let contextByModel = new Map();
|
|
22
23
|
export function supportsAdaptiveThinking(model) {
|
|
23
24
|
return ADAPTIVE.test(model);
|
|
24
25
|
}
|
|
@@ -41,12 +42,24 @@ export const anthropic = {
|
|
|
41
42
|
},
|
|
42
43
|
// Newest first is how the endpoint already answers, so the order is left
|
|
43
44
|
// exactly as it arrives rather than re-sorted into something less useful.
|
|
44
|
-
models(signal, onStatus) {
|
|
45
|
-
|
|
45
|
+
async models(signal, onStatus) {
|
|
46
|
+
const catalog = await loadModels(signal, onStatus);
|
|
47
|
+
contextByModel = catalog.contexts;
|
|
48
|
+
return catalog.ids;
|
|
46
49
|
},
|
|
47
50
|
async efforts(model) {
|
|
48
51
|
return anthropicEfforts(model);
|
|
49
52
|
},
|
|
53
|
+
async contextWindow(model, signal, onStatus) {
|
|
54
|
+
if (contextByModel.has(model))
|
|
55
|
+
return contextByModel.get(model);
|
|
56
|
+
const catalog = await loadModels(signal, onStatus);
|
|
57
|
+
contextByModel = catalog.contexts;
|
|
58
|
+
const context = contextByModel.get(model);
|
|
59
|
+
if (!contextByModel.has(model))
|
|
60
|
+
contextByModel.set(model, undefined);
|
|
61
|
+
return context;
|
|
62
|
+
},
|
|
50
63
|
location: () => "cloud",
|
|
51
64
|
async send(req) {
|
|
52
65
|
const key = requireKey();
|
|
@@ -77,6 +90,23 @@ export const anthropic = {
|
|
|
77
90
|
return fromWireResponse(data);
|
|
78
91
|
},
|
|
79
92
|
};
|
|
93
|
+
async function loadModels(signal, onStatus) {
|
|
94
|
+
const entries = await modelCatalog(MODELS, headers(requireKey()), signal, onStatus);
|
|
95
|
+
return {
|
|
96
|
+
ids: entries.map((entry) => entry.id),
|
|
97
|
+
contexts: new Map(entries.map((entry) => [
|
|
98
|
+
entry.id,
|
|
99
|
+
contextWindow(entry.metadata["max_input_tokens"]),
|
|
100
|
+
])),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function contextWindow(value) {
|
|
104
|
+
return validTokenCount(value) ? Object.freeze({ tokens: value }) : undefined;
|
|
105
|
+
}
|
|
106
|
+
function validTokenCount(value) {
|
|
107
|
+
return typeof value === "number" && Number.isSafeInteger(value) &&
|
|
108
|
+
value >= 4_096 && value <= 10_000_000;
|
|
109
|
+
}
|
|
80
110
|
// Read at the moment it is used, never captured at import: a key typed into
|
|
81
111
|
// the running window has to count, and so does one exported after startup.
|
|
82
112
|
function apiKey() {
|
|
@@ -13,6 +13,9 @@ export const MAX_MODEL_CATALOG_ENTRIES = 1_000;
|
|
|
13
13
|
export const MAX_MODEL_CATALOG_ITEMS = 4_000;
|
|
14
14
|
export const MAX_MODEL_ID_CHARS = 256;
|
|
15
15
|
export async function listModels(url, headers, signal, onStatus) {
|
|
16
|
+
return (await modelCatalog(url, headers, signal, onStatus)).map((entry) => entry.id);
|
|
17
|
+
}
|
|
18
|
+
export async function modelCatalog(url, headers, signal, onStatus) {
|
|
16
19
|
const body = await getJson(url, headers, signal, onStatus);
|
|
17
20
|
const data = body.data;
|
|
18
21
|
if (!Array.isArray(data))
|
|
@@ -22,16 +25,20 @@ export async function listModels(url, headers, signal, onStatus) {
|
|
|
22
25
|
const inspected = Math.min(data.length, MAX_MODEL_CATALOG_ITEMS);
|
|
23
26
|
for (let index = 0; index < inspected && models.length < MAX_MODEL_CATALOG_ENTRIES; index++) {
|
|
24
27
|
const entry = data[index];
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
+
const metadata = record(entry) ? entry : undefined;
|
|
29
|
+
if (metadata === undefined)
|
|
30
|
+
continue;
|
|
31
|
+
const id = metadata["id"];
|
|
28
32
|
if (typeof id !== "string" ||
|
|
29
33
|
id === "" ||
|
|
30
34
|
id.length > MAX_MODEL_ID_CHARS ||
|
|
31
35
|
unique.has(id))
|
|
32
36
|
continue;
|
|
33
37
|
unique.add(id);
|
|
34
|
-
models.push(id);
|
|
38
|
+
models.push({ id, metadata });
|
|
35
39
|
}
|
|
36
40
|
return models;
|
|
37
41
|
}
|
|
42
|
+
function record(value) {
|
|
43
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
44
|
+
}
|
package/dist/providers/ollama.js
CHANGED
|
@@ -5,14 +5,19 @@
|
|
|
5
5
|
// daemon. There is no default model — the catalogue is whatever the host has
|
|
6
6
|
// pulled or the subscription grants — so the model has to be named with
|
|
7
7
|
// --model.
|
|
8
|
-
import {
|
|
8
|
+
import { requireSupportedEffort } from "../effort.js";
|
|
9
|
+
import { getJson, postJson, postSse } from "./http.js";
|
|
9
10
|
import { listModels } from "./catalog.js";
|
|
10
11
|
import { keyFor } from "../credentials.js";
|
|
11
12
|
import { assembleOllama } from "./ollama-stream.js";
|
|
12
13
|
import { OLLAMA_CLOUD_HOST, OLLAMA_LOCAL_HOST, ollamaConnectionKind, parseOllamaEndpoint, } from "./ollama-endpoint.js";
|
|
13
14
|
import { fromWireReply, stopNotice, toWireMessages, toWireTool } from "./ollama-wire.js";
|
|
14
15
|
const KEY = "OLLAMA_API_KEY";
|
|
16
|
+
// Ollama also accepts `none`; Jecode's product-wide reasoning floor is `low`.
|
|
17
|
+
const OLLAMA_EFFORTS = ["low", "medium", "high"];
|
|
15
18
|
let configuredHost;
|
|
19
|
+
const CONTEXT_CACHE_MS = 30_000;
|
|
20
|
+
const contextByEndpoint = new Map();
|
|
16
21
|
/** Set the endpoint selected for this process. Undefined restores key-aware inference. */
|
|
17
22
|
export function configureOllama(host) {
|
|
18
23
|
configuredHost = host === undefined ? undefined : parseOllamaEndpoint(host).baseUrl;
|
|
@@ -46,7 +51,22 @@ export const ollama = {
|
|
|
46
51
|
return listModels(`${at.baseUrl}/v1/models`, headers(at), signal, onStatus);
|
|
47
52
|
},
|
|
48
53
|
async efforts() {
|
|
49
|
-
return
|
|
54
|
+
return OLLAMA_EFFORTS;
|
|
55
|
+
},
|
|
56
|
+
async contextWindow(model, signal, onStatus) {
|
|
57
|
+
const at = endpoint();
|
|
58
|
+
const cacheKey = `${at.baseUrl}\u0000${model}`;
|
|
59
|
+
const cached = contextByEndpoint.get(cacheKey);
|
|
60
|
+
if (cached !== undefined && cached.expiresAt > Date.now())
|
|
61
|
+
return cached.value;
|
|
62
|
+
const observed = await nativeContextWindow(at, model, signal, onStatus);
|
|
63
|
+
if (observed?.runtime === true) {
|
|
64
|
+
contextByEndpoint.set(cacheKey, {
|
|
65
|
+
value: observed.value,
|
|
66
|
+
expiresAt: Date.now() + CONTEXT_CACHE_MS,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return observed?.value;
|
|
50
70
|
},
|
|
51
71
|
location: () => {
|
|
52
72
|
try {
|
|
@@ -58,13 +78,15 @@ export const ollama = {
|
|
|
58
78
|
},
|
|
59
79
|
async send(req) {
|
|
60
80
|
const at = endpoint();
|
|
61
|
-
|
|
62
|
-
//
|
|
81
|
+
const effort = requireSupportedEffort(req.model, req.effort, OLLAMA_EFFORTS);
|
|
82
|
+
// The OpenAI-compatible endpoint accepts this vocabulary for thinking
|
|
83
|
+
// models. Invalid levels are rejected locally instead of being rewritten.
|
|
63
84
|
const events = await postSse(`${at.baseUrl}/v1/chat/completions`, headers(at), {
|
|
64
85
|
model: req.model,
|
|
65
86
|
messages: toWireMessages(req.system, req.messages),
|
|
66
87
|
tools: req.tools.map(toWireTool),
|
|
67
88
|
max_tokens: req.maxTokens,
|
|
89
|
+
reasoning_effort: effort,
|
|
68
90
|
stream: true,
|
|
69
91
|
}, req.signal, req.onStatus);
|
|
70
92
|
const reply = await assembleOllama(events, req.onStream);
|
|
@@ -74,6 +96,63 @@ export const ollama = {
|
|
|
74
96
|
return fromWireReply(reply);
|
|
75
97
|
},
|
|
76
98
|
};
|
|
99
|
+
async function nativeContextWindow(at, model, signal, onStatus) {
|
|
100
|
+
try {
|
|
101
|
+
const running = await getJson(`${at.baseUrl}/api/ps`, headers(at), signal, onStatus);
|
|
102
|
+
const allocated = runningContext(running, model);
|
|
103
|
+
if (allocated !== undefined)
|
|
104
|
+
return { value: usableContext(allocated), runtime: true };
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
throwIfAborted(signal, error);
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const details = await postJson(`${at.baseUrl}/api/show`, headers(at), { model }, signal, onStatus);
|
|
111
|
+
const capacity = modelCapacity(details);
|
|
112
|
+
return capacity === undefined
|
|
113
|
+
? undefined
|
|
114
|
+
: { value: usableContext(capacity), runtime: false };
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
throwIfAborted(signal, error);
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function runningContext(value, model) {
|
|
122
|
+
if (!record(value) || !Array.isArray(value["models"]))
|
|
123
|
+
return undefined;
|
|
124
|
+
for (const entry of value["models"]) {
|
|
125
|
+
if (!record(entry))
|
|
126
|
+
continue;
|
|
127
|
+
if (entry["name"] !== model && entry["model"] !== model)
|
|
128
|
+
continue;
|
|
129
|
+
if (validTokenCount(entry["context_length"]))
|
|
130
|
+
return entry["context_length"];
|
|
131
|
+
}
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
function modelCapacity(value) {
|
|
135
|
+
if (!record(value) || !record(value["model_info"]))
|
|
136
|
+
return undefined;
|
|
137
|
+
const capacities = Object.entries(value["model_info"])
|
|
138
|
+
.filter(([name, count]) => name.endsWith(".context_length") && validTokenCount(count))
|
|
139
|
+
.map(([, count]) => count);
|
|
140
|
+
return capacities.length === 0 ? undefined : Math.max(...capacities);
|
|
141
|
+
}
|
|
142
|
+
function validTokenCount(value) {
|
|
143
|
+
return typeof value === "number" && Number.isSafeInteger(value) &&
|
|
144
|
+
value >= 4_096 && value <= 10_000_000;
|
|
145
|
+
}
|
|
146
|
+
function usableContext(tokens) {
|
|
147
|
+
return Object.freeze({ tokens: Math.floor(tokens * 95 / 100) });
|
|
148
|
+
}
|
|
149
|
+
function record(value) {
|
|
150
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
151
|
+
}
|
|
152
|
+
function throwIfAborted(signal, error) {
|
|
153
|
+
if (signal?.aborted === true)
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
77
156
|
function endpoint() {
|
|
78
157
|
return ollamaConnection();
|
|
79
158
|
}
|
|
@@ -19,6 +19,7 @@ const MAX_MODELS = 1_000;
|
|
|
19
19
|
const MAX_MODEL_CHARS = 256;
|
|
20
20
|
const XHIGH_EFFORTS = ["low", "medium", "high", "xhigh"];
|
|
21
21
|
let effortByModel = new Map();
|
|
22
|
+
let contextByModel = new Map();
|
|
22
23
|
export const openaiCodex = {
|
|
23
24
|
id: ID,
|
|
24
25
|
defaultModel: "",
|
|
@@ -29,7 +30,7 @@ export const openaiCodex = {
|
|
|
29
30
|
location: () => "cloud",
|
|
30
31
|
async models(signal, onStatus) {
|
|
31
32
|
const catalog = await loadCatalog(signal, onStatus);
|
|
32
|
-
|
|
33
|
+
rememberCatalog(catalog);
|
|
33
34
|
return catalog.ids;
|
|
34
35
|
},
|
|
35
36
|
async efforts(model, signal, onStatus) {
|
|
@@ -37,9 +38,19 @@ export const openaiCodex = {
|
|
|
37
38
|
if (cached !== undefined)
|
|
38
39
|
return cached;
|
|
39
40
|
const catalog = await loadCatalog(signal, onStatus);
|
|
40
|
-
|
|
41
|
+
rememberCatalog(catalog);
|
|
41
42
|
return effortByModel.get(model) ?? fallbackEfforts(model);
|
|
42
43
|
},
|
|
44
|
+
async contextWindow(model, signal, onStatus) {
|
|
45
|
+
if (contextByModel.has(model))
|
|
46
|
+
return contextByModel.get(model);
|
|
47
|
+
const catalog = await loadCatalog(signal, onStatus);
|
|
48
|
+
rememberCatalog(catalog);
|
|
49
|
+
const context = contextByModel.get(model);
|
|
50
|
+
if (!contextByModel.has(model))
|
|
51
|
+
contextByModel.set(model, undefined);
|
|
52
|
+
return context;
|
|
53
|
+
},
|
|
43
54
|
async send(req) {
|
|
44
55
|
const efforts = effortByModel.get(req.model) ?? fallbackEfforts(req.model);
|
|
45
56
|
const effort = requireSupportedEffort(req.model, req.effort, efforts);
|
|
@@ -75,6 +86,10 @@ async function loadCatalog(signal, onStatus) {
|
|
|
75
86
|
return modelCatalog(body);
|
|
76
87
|
}, signal, onStatus);
|
|
77
88
|
}
|
|
89
|
+
function rememberCatalog(catalog) {
|
|
90
|
+
effortByModel = catalog.efforts;
|
|
91
|
+
contextByModel = catalog.contexts;
|
|
92
|
+
}
|
|
78
93
|
async function withAuthorization(operation, signal, onStatus) {
|
|
79
94
|
let authorization = await openAIAuthorization(undefined, signal, onStatus);
|
|
80
95
|
try {
|
|
@@ -120,6 +135,7 @@ function modelCatalog(value) {
|
|
|
120
135
|
id,
|
|
121
136
|
priority: typeof entry["priority"] === "number" ? entry["priority"] : 0,
|
|
122
137
|
efforts: reasoningLevels(entry, id),
|
|
138
|
+
context: modelContextWindow(entry),
|
|
123
139
|
}];
|
|
124
140
|
})
|
|
125
141
|
.sort((left, right) => left.priority - right.priority)
|
|
@@ -127,8 +143,36 @@ function modelCatalog(value) {
|
|
|
127
143
|
return {
|
|
128
144
|
ids: models.map((entry) => entry.id),
|
|
129
145
|
efforts: new Map(models.map((entry) => [entry.id, entry.efforts])),
|
|
146
|
+
contexts: new Map(models.map((entry) => [entry.id, entry.context])),
|
|
130
147
|
};
|
|
131
148
|
}
|
|
149
|
+
function modelContextWindow(entry) {
|
|
150
|
+
const resolved = tokenCount(entry["context_window"]) ?? tokenCount(entry["max_context_window"]);
|
|
151
|
+
if (resolved === undefined)
|
|
152
|
+
return undefined;
|
|
153
|
+
const percent = percentage(entry["effective_context_window_percent"]) ?? 95;
|
|
154
|
+
const tokens = Math.floor(resolved * percent / 100);
|
|
155
|
+
if (!validTokenCount(tokens))
|
|
156
|
+
return undefined;
|
|
157
|
+
const automatic = Math.floor(resolved * 9 / 10);
|
|
158
|
+
const advertised = tokenCount(entry["auto_compact_token_limit"]);
|
|
159
|
+
return Object.freeze({
|
|
160
|
+
tokens,
|
|
161
|
+
compactAtTokens: Math.min(advertised ?? automatic, automatic, tokens),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
function tokenCount(value) {
|
|
165
|
+
return validTokenCount(value) ? value : undefined;
|
|
166
|
+
}
|
|
167
|
+
function validTokenCount(value) {
|
|
168
|
+
return typeof value === "number" && Number.isSafeInteger(value) &&
|
|
169
|
+
value >= 4_096 && value <= 10_000_000;
|
|
170
|
+
}
|
|
171
|
+
function percentage(value) {
|
|
172
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 1 && value <= 100
|
|
173
|
+
? value
|
|
174
|
+
: undefined;
|
|
175
|
+
}
|
|
132
176
|
function reasoningLevels(entry, model) {
|
|
133
177
|
const source = entry["supported_reasoning_levels"];
|
|
134
178
|
if (!Array.isArray(source))
|