nou-cli 0.0.0-stage → 0.2.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/.env.example +24 -0
- package/README.md +163 -2
- package/dist/grammars/tree-sitter-bash.wasm +0 -0
- package/dist/grammars/tree-sitter-c.wasm +0 -0
- package/dist/grammars/tree-sitter-css.wasm +0 -0
- package/dist/grammars/tree-sitter-go.wasm +0 -0
- package/dist/grammars/tree-sitter-html.wasm +0 -0
- package/dist/grammars/tree-sitter-java.wasm +0 -0
- package/dist/grammars/tree-sitter-javascript.wasm +0 -0
- package/dist/grammars/tree-sitter-json.wasm +0 -0
- package/dist/grammars/tree-sitter-python.wasm +0 -0
- package/dist/grammars/tree-sitter-rust.wasm +0 -0
- package/dist/grammars/tree-sitter-toml.wasm +0 -0
- package/dist/grammars/tree-sitter-tsx.wasm +0 -0
- package/dist/grammars/tree-sitter-typescript.wasm +0 -0
- package/dist/grammars/tree-sitter-yaml.wasm +0 -0
- package/dist/index.js +84 -0
- package/dist/src/agent/index.js +246 -0
- package/dist/src/commands/index.js +125 -0
- package/dist/src/config/index.js +39 -0
- package/dist/src/context/index.js +53 -0
- package/dist/src/diagnostics/index.js +235 -0
- package/dist/src/diagnostics/process.js +64 -0
- package/dist/src/diagnostics/providers.js +103 -0
- package/dist/src/diagnostics/types.js +1 -0
- package/dist/src/env.js +12 -0
- package/dist/src/git/index.js +94 -0
- package/dist/src/git/tools.js +52 -0
- package/dist/src/history/index.js +113 -0
- package/dist/src/lsp/client.js +169 -0
- package/dist/src/lsp/index.js +169 -0
- package/dist/src/plugins/index.js +134 -0
- package/dist/src/providers/anthropic.js +109 -0
- package/dist/src/providers/ids.js +3 -0
- package/dist/src/providers/index.js +46 -0
- package/dist/src/providers/ollama.js +58 -0
- package/dist/src/providers/openai.js +107 -0
- package/dist/src/providers/types.js +2 -0
- package/dist/src/syntax/index.js +240 -0
- package/dist/src/tools/index.js +100 -0
- package/dist/src/ui/App.js +671 -0
- package/dist/src/ui/Chat.js +97 -0
- package/dist/src/ui/ChatInput.js +128 -0
- package/dist/src/ui/CodeView.js +100 -0
- package/dist/src/ui/Editor.js +234 -0
- package/dist/src/ui/FileTree.js +143 -0
- package/dist/src/ui/Header.js +9 -0
- package/dist/src/ui/Pane.js +8 -0
- package/dist/src/ui/ProblemsView.js +71 -0
- package/dist/src/ui/QuickOpen.js +91 -0
- package/dist/src/ui/Settings.js +165 -0
- package/dist/src/ui/text.js +100 -0
- package/dist/src/ui/theme.js +41 -0
- package/dist/src/update/index.js +102 -0
- package/dist/src/util/index.js +9 -0
- package/package.json +69 -4
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { createHash } from "crypto";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { pathToFileURL } from "url";
|
|
5
|
+
import { NOU_HOME } from "../config/index.js";
|
|
6
|
+
import { eslintProvider, ruffProvider, typescriptProvider } from "../diagnostics/providers.js";
|
|
7
|
+
import { syntaxProvider } from "../syntax/index.js";
|
|
8
|
+
import { lsp } from "../lsp/index.js";
|
|
9
|
+
/** Plugins that ship with Nou. On by default; they only run when the project has the tool installed. */
|
|
10
|
+
export const BUILTIN_PLUGINS = [
|
|
11
|
+
{
|
|
12
|
+
id: "syntax",
|
|
13
|
+
file: "(built-in)",
|
|
14
|
+
scope: "built-in",
|
|
15
|
+
plugin: { name: "Syntax check", description: "Instant syntax errors from the tree-sitter parser, no setup needed.", diagnostics: [syntaxProvider] },
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: "eslint",
|
|
19
|
+
file: "(built-in)",
|
|
20
|
+
scope: "built-in",
|
|
21
|
+
plugin: { name: "ESLint", description: "Lints JS/TS with your project's own ESLint and config.", diagnostics: [eslintProvider] },
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: "typescript",
|
|
25
|
+
file: "(built-in)",
|
|
26
|
+
scope: "built-in",
|
|
27
|
+
plugin: { name: "TypeScript", description: "Type errors from your project's own tsc (needs tsconfig.json).", diagnostics: [typescriptProvider] },
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: "ruff",
|
|
31
|
+
file: "(built-in)",
|
|
32
|
+
scope: "built-in",
|
|
33
|
+
plugin: { name: "Ruff", description: "Lints Python with ruff, if it's installed.", diagnostics: [ruffProvider] },
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "lsp",
|
|
37
|
+
file: "(built-in)",
|
|
38
|
+
scope: "built-in",
|
|
39
|
+
plugin: {
|
|
40
|
+
name: "Language servers",
|
|
41
|
+
description: "Problems, hover (Ctrl+K) and go-to-definition (Ctrl+G) from installed servers: typescript-language-server, pyright, rust-analyzer, gopls, clangd.",
|
|
42
|
+
diagnostics: [lsp.provider()],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
/**
|
|
47
|
+
* Built-ins are on unless switched off. Your own plugins run only if you approved this exact file and
|
|
48
|
+
* content; approval is never by name, so `.nou/plugins/x.js` in a cloned repo can't ride on an approval
|
|
49
|
+
* you gave a different `x.js`.
|
|
50
|
+
*/
|
|
51
|
+
export function isEnabled(entry, settings) {
|
|
52
|
+
if (entry.scope === "built-in")
|
|
53
|
+
return settings.plugins[entry.id] !== false;
|
|
54
|
+
return !!entry.root && !!entry.hash && settings.trustedPlugins[entry.root] === entry.hash;
|
|
55
|
+
}
|
|
56
|
+
/** "changed" means you approved this plugin, but its code has changed since. */
|
|
57
|
+
export function trustState(entry, settings) {
|
|
58
|
+
if (isEnabled(entry, settings))
|
|
59
|
+
return "on";
|
|
60
|
+
return entry.root && settings.trustedPlugins[entry.root] ? "changed" : "off";
|
|
61
|
+
}
|
|
62
|
+
/** The settings change that switches a plugin on or off. */
|
|
63
|
+
export function trustPatch(entry, on, settings) {
|
|
64
|
+
if (entry.scope === "built-in")
|
|
65
|
+
return { plugins: { ...settings.plugins, [entry.id]: on } };
|
|
66
|
+
const trusted = { ...settings.trustedPlugins };
|
|
67
|
+
if (on && entry.root && entry.hash)
|
|
68
|
+
trusted[entry.root] = entry.hash;
|
|
69
|
+
else if (entry.root)
|
|
70
|
+
delete trusted[entry.root];
|
|
71
|
+
return { trustedPlugins: trusted };
|
|
72
|
+
}
|
|
73
|
+
function hashPlugin(root) {
|
|
74
|
+
const hash = createHash("sha256");
|
|
75
|
+
const add = (p) => {
|
|
76
|
+
if (statSync(p).isDirectory()) {
|
|
77
|
+
for (const name of readdirSync(p).sort())
|
|
78
|
+
if (name !== "node_modules" && name !== ".git")
|
|
79
|
+
add(path.join(p, name));
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
hash.update(path.relative(root, p)).update(readFileSync(p));
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
add(root);
|
|
86
|
+
return hash.digest("hex");
|
|
87
|
+
}
|
|
88
|
+
export const PLUGIN_DIRS = [
|
|
89
|
+
{ scope: "project", dir: path.join(process.cwd(), ".nou", "plugins") },
|
|
90
|
+
{ scope: "user", dir: path.join(NOU_HOME, "plugins") },
|
|
91
|
+
];
|
|
92
|
+
const EXTENSIONS = [".js", ".mjs", ".ts", ".mts"];
|
|
93
|
+
/** Finds plugin files without running them. */
|
|
94
|
+
export function discoverPlugins() {
|
|
95
|
+
const found = [];
|
|
96
|
+
for (const { scope, dir } of PLUGIN_DIRS) {
|
|
97
|
+
if (!existsSync(dir))
|
|
98
|
+
continue;
|
|
99
|
+
for (const name of readdirSync(dir)) {
|
|
100
|
+
const full = path.join(dir, name);
|
|
101
|
+
let file;
|
|
102
|
+
if (statSync(full).isDirectory()) {
|
|
103
|
+
file = EXTENSIONS.map((ext) => path.join(full, `index${ext}`)).find(existsSync);
|
|
104
|
+
}
|
|
105
|
+
else if (EXTENSIONS.includes(path.extname(name))) {
|
|
106
|
+
file = full;
|
|
107
|
+
}
|
|
108
|
+
const id = path.basename(name, path.extname(name));
|
|
109
|
+
if (file && !found.some((p) => p.id === id)) {
|
|
110
|
+
let hash;
|
|
111
|
+
try {
|
|
112
|
+
hash = hashPlugin(full);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// unreadable: stays untrusted
|
|
116
|
+
}
|
|
117
|
+
found.push({ id, file, root: full, scope, ...(hash ? { hash } : {}) });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return found;
|
|
122
|
+
}
|
|
123
|
+
export async function loadPlugin(entry) {
|
|
124
|
+
try {
|
|
125
|
+
const mod = await import(pathToFileURL(entry.file).href);
|
|
126
|
+
const plugin = mod.default ?? mod.plugin;
|
|
127
|
+
if (!plugin || typeof plugin.name !== "string")
|
|
128
|
+
throw new Error("must default-export an object with a name");
|
|
129
|
+
return { ...entry, plugin };
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
return { ...entry, error: error.message };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
// Models that accept server-side refusal fallbacks in their "default" form.
|
|
6
|
+
const FALLBACK_MODELS = /^claude-(fable-5-1|opus-5-5|opus-5|sonnet-5-5)$/;
|
|
7
|
+
// Current-generation models take output_config.effort.
|
|
8
|
+
const EFFORT_MODELS = /^claude-(fable|mythos|opus|sonnet|haiku)-5/;
|
|
9
|
+
const MAX_JSON_RETRIES = 2;
|
|
10
|
+
function toAnthropic(messages) {
|
|
11
|
+
const out = [];
|
|
12
|
+
for (const m of messages) {
|
|
13
|
+
if (m.role === "user") {
|
|
14
|
+
out.push({ role: "user", content: m.content });
|
|
15
|
+
}
|
|
16
|
+
else if (m.role === "assistant") {
|
|
17
|
+
// Replay Claude's own turn unchanged (keeps thinking blocks valid); rebuild turns from other providers.
|
|
18
|
+
const content = m.raw?.provider === "anthropic"
|
|
19
|
+
? m.raw.data
|
|
20
|
+
: [
|
|
21
|
+
...(m.content ? [{ type: "text", text: m.content }] : []),
|
|
22
|
+
...m.toolCalls.map((c) => ({ type: "tool_use", id: c.id, name: c.name, input: c.args })),
|
|
23
|
+
];
|
|
24
|
+
out.push({ role: "assistant", content });
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
const result = { type: "tool_result", tool_use_id: m.callId, content: m.content, ...(m.isError ? { is_error: true } : {}) };
|
|
28
|
+
// All results for one turn go back in a single user message.
|
|
29
|
+
const last = out[out.length - 1];
|
|
30
|
+
if (last?.role === "user" && Array.isArray(last.content) && last.content.every((b) => b.type === "tool_result")) {
|
|
31
|
+
last.content.push(result);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
out.push({ role: "user", content: [result] });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
function hasCredentials() {
|
|
41
|
+
return !!(process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || process.env.ANTHROPIC_PROFILE)
|
|
42
|
+
|| existsSync(path.join(os.homedir(), ".config", "anthropic"));
|
|
43
|
+
}
|
|
44
|
+
export function anthropicProvider() {
|
|
45
|
+
let client = null;
|
|
46
|
+
const getClient = () => (client ??= new Anthropic());
|
|
47
|
+
return {
|
|
48
|
+
id: "anthropic",
|
|
49
|
+
label: "Anthropic (Claude)",
|
|
50
|
+
local: false,
|
|
51
|
+
defaultModel: "claude-opus-5-5",
|
|
52
|
+
unavailable: () => (hasCredentials() ? null : "set ANTHROPIC_API_KEY (or run `ant auth login`)"),
|
|
53
|
+
async chat({ model, system, messages, tools, onText, signal, effort: requested }) {
|
|
54
|
+
const fallbacks = FALLBACK_MODELS.test(model);
|
|
55
|
+
const effort = EFFORT_MODELS.test(model) ? process.env.NOU_EFFORT ?? requested ?? "high" : undefined;
|
|
56
|
+
const body = {
|
|
57
|
+
model,
|
|
58
|
+
max_tokens: 64000,
|
|
59
|
+
system: [{ type: "text", text: system, cache_control: { type: "ephemeral" } }],
|
|
60
|
+
messages: toAnthropic(messages),
|
|
61
|
+
tools: tools.map((t) => ({
|
|
62
|
+
name: t.name,
|
|
63
|
+
description: t.description,
|
|
64
|
+
input_schema: t.parameters,
|
|
65
|
+
eager_input_streaming: true,
|
|
66
|
+
})),
|
|
67
|
+
stream: true,
|
|
68
|
+
...(effort ? { output_config: { effort: effort } } : {}),
|
|
69
|
+
...(fallbacks ? { betas: ["server-side-fallback-2026-07-01"], fallbacks: "default" } : {}),
|
|
70
|
+
};
|
|
71
|
+
for (let attempt = 0;; attempt++) {
|
|
72
|
+
let text = "";
|
|
73
|
+
try {
|
|
74
|
+
const stream = getClient().beta.messages.stream(body, signal ? { signal } : {});
|
|
75
|
+
stream.on("text", (delta) => {
|
|
76
|
+
text += delta;
|
|
77
|
+
onText(delta);
|
|
78
|
+
});
|
|
79
|
+
const message = await stream.finalMessage();
|
|
80
|
+
const toolCalls = message.content
|
|
81
|
+
.filter((b) => b.type === "tool_use")
|
|
82
|
+
.map((b) => ({ id: b.id, name: b.name, args: (b.input ?? {}) }));
|
|
83
|
+
const stop = message.stop_reason === "refusal"
|
|
84
|
+
? "refusal"
|
|
85
|
+
: message.stop_reason === "max_tokens" && toolCalls.length ? "max_tokens" : undefined;
|
|
86
|
+
return {
|
|
87
|
+
text: message.content.filter((b) => b.type === "text").map((b) => b.text).join(""),
|
|
88
|
+
toolCalls,
|
|
89
|
+
raw: { provider: "anthropic", data: message.content },
|
|
90
|
+
...(stop ? { stop } : {}),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
// With eager input streaming, unparseable tool JSON surfaces as a plain (non-API) error: re-issue a few times.
|
|
95
|
+
if (error instanceof Anthropic.APIError || attempt >= MAX_JSON_RETRIES || signal?.aborted)
|
|
96
|
+
throw error;
|
|
97
|
+
if (text)
|
|
98
|
+
onText("\n");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
async listModels() {
|
|
103
|
+
const models = [];
|
|
104
|
+
for await (const m of getClient().models.list())
|
|
105
|
+
models.push(m.id);
|
|
106
|
+
return models;
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { loadSettings, saveSettings } from "../config/index.js";
|
|
2
|
+
import { anthropicProvider } from "./anthropic.js";
|
|
3
|
+
import { ollamaProvider } from "./ollama.js";
|
|
4
|
+
import { OPENAI_COMPATIBLE, openAICompatibleProvider } from "./openai.js";
|
|
5
|
+
export const providers = [
|
|
6
|
+
ollamaProvider(),
|
|
7
|
+
...OPENAI_COMPATIBLE.filter((p) => p.local).map(openAICompatibleProvider),
|
|
8
|
+
anthropicProvider(),
|
|
9
|
+
...OPENAI_COMPATIBLE.filter((p) => !p.local).map(openAICompatibleProvider),
|
|
10
|
+
];
|
|
11
|
+
/**
|
|
12
|
+
* Parses "provider:model" (e.g. "anthropic:claude-opus-5-5", "ollama:qwen3:8b").
|
|
13
|
+
* A bare provider id picks its default model; anything else is treated as an Ollama model name.
|
|
14
|
+
*/
|
|
15
|
+
export function resolveModel(spec) {
|
|
16
|
+
const trimmed = spec.trim();
|
|
17
|
+
const colon = trimmed.indexOf(":");
|
|
18
|
+
const head = colon >= 0 ? trimmed.slice(0, colon) : trimmed;
|
|
19
|
+
const provider = providers.find((p) => p.id === head.toLowerCase());
|
|
20
|
+
if (provider) {
|
|
21
|
+
const model = colon >= 0 ? trimmed.slice(colon + 1) : "";
|
|
22
|
+
return { provider, model: model || provider.defaultModel };
|
|
23
|
+
}
|
|
24
|
+
return { provider: providers[0], model: trimmed };
|
|
25
|
+
}
|
|
26
|
+
export const describe = (choice) => `${choice.provider.id}:${choice.model}`;
|
|
27
|
+
/** NOU_MODEL wins, then the model saved in settings, then local Ollama. */
|
|
28
|
+
export function initialModel() {
|
|
29
|
+
if (process.env.NOU_MODEL)
|
|
30
|
+
return resolveModel(process.env.NOU_MODEL);
|
|
31
|
+
return resolveModel(loadSettings().model || "ollama");
|
|
32
|
+
}
|
|
33
|
+
export function saveModel(choice) {
|
|
34
|
+
saveSettings({ model: describe(choice) });
|
|
35
|
+
}
|
|
36
|
+
/** One-shot completion without tools, used for things like generating NOU.md. */
|
|
37
|
+
export async function complete(choice, prompt) {
|
|
38
|
+
const result = await choice.provider.chat({
|
|
39
|
+
model: choice.model,
|
|
40
|
+
system: "You are a helpful assistant.",
|
|
41
|
+
messages: [{ role: "user", content: prompt }],
|
|
42
|
+
tools: [],
|
|
43
|
+
onText: () => { },
|
|
44
|
+
});
|
|
45
|
+
return result.text;
|
|
46
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Ollama } from "ollama";
|
|
2
|
+
import { newCallId } from "./ids.js";
|
|
3
|
+
export function ollamaProvider() {
|
|
4
|
+
const host = process.env.OLLAMA_HOST ?? "http://127.0.0.1:11434";
|
|
5
|
+
const client = new Ollama({ host });
|
|
6
|
+
const toOllama = (system, messages) => [
|
|
7
|
+
{ role: "system", content: system },
|
|
8
|
+
...messages.map((m) => {
|
|
9
|
+
if (m.role === "user")
|
|
10
|
+
return { role: "user", content: m.content };
|
|
11
|
+
if (m.role === "tool")
|
|
12
|
+
return { role: "tool", content: m.content, tool_name: m.name };
|
|
13
|
+
return {
|
|
14
|
+
role: "assistant",
|
|
15
|
+
content: m.content,
|
|
16
|
+
tool_calls: m.toolCalls.map((c) => ({ function: { name: c.name, arguments: c.args } })),
|
|
17
|
+
};
|
|
18
|
+
}),
|
|
19
|
+
];
|
|
20
|
+
return {
|
|
21
|
+
id: "ollama",
|
|
22
|
+
label: `Ollama (${host})`,
|
|
23
|
+
local: true,
|
|
24
|
+
defaultModel: "qwen3:8b",
|
|
25
|
+
unavailable: () => null,
|
|
26
|
+
async chat({ model, system, messages, tools, onText, signal }) {
|
|
27
|
+
const stream = await client.chat({
|
|
28
|
+
model,
|
|
29
|
+
messages: toOllama(system, messages),
|
|
30
|
+
tools: tools.map((t) => ({ type: "function", function: t })),
|
|
31
|
+
stream: true,
|
|
32
|
+
});
|
|
33
|
+
const onAbort = () => stream.abort();
|
|
34
|
+
signal?.addEventListener("abort", onAbort);
|
|
35
|
+
let text = "";
|
|
36
|
+
const toolCalls = [];
|
|
37
|
+
try {
|
|
38
|
+
for await (const part of stream) {
|
|
39
|
+
if (part.message.content) {
|
|
40
|
+
text += part.message.content;
|
|
41
|
+
onText(part.message.content);
|
|
42
|
+
}
|
|
43
|
+
for (const call of part.message.tool_calls ?? []) {
|
|
44
|
+
toolCalls.push({ id: newCallId(), name: call.function.name, args: call.function.arguments ?? {} });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
signal?.removeEventListener("abort", onAbort);
|
|
50
|
+
}
|
|
51
|
+
return { text, toolCalls };
|
|
52
|
+
},
|
|
53
|
+
async listModels() {
|
|
54
|
+
const { models } = await client.list();
|
|
55
|
+
return models.map((m) => m.name);
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
import { newCallId } from "./ids.js";
|
|
3
|
+
/** Everything that speaks the OpenAI chat completions API. */
|
|
4
|
+
export const OPENAI_COMPATIBLE = [
|
|
5
|
+
{ id: "openai", label: "OpenAI", baseURL: "https://api.openai.com/v1", keyEnv: "OPENAI_API_KEY", defaultModel: "gpt-5", local: false },
|
|
6
|
+
{ id: "gemini", label: "Google Gemini", baseURL: "https://generativelanguage.googleapis.com/v1beta/openai/", keyEnv: "GEMINI_API_KEY", defaultModel: "gemini-2.5-pro", local: false },
|
|
7
|
+
{ id: "openrouter", label: "OpenRouter", baseURL: "https://openrouter.ai/api/v1", keyEnv: "OPENROUTER_API_KEY", defaultModel: "anthropic/claude-opus-5-5", local: false },
|
|
8
|
+
{ id: "groq", label: "Groq", baseURL: "https://api.groq.com/openai/v1", keyEnv: "GROQ_API_KEY", defaultModel: "llama-3.3-70b-versatile", local: false },
|
|
9
|
+
{ id: "deepseek", label: "DeepSeek", baseURL: "https://api.deepseek.com/v1", keyEnv: "DEEPSEEK_API_KEY", defaultModel: "deepseek-chat", local: false },
|
|
10
|
+
{ id: "lmstudio", label: "LM Studio", baseURL: "http://localhost:1234/v1", urlEnv: "LMSTUDIO_BASE_URL", defaultModel: "local-model", local: true },
|
|
11
|
+
{ id: "llamacpp", label: "llama.cpp server", baseURL: "http://localhost:8080/v1", urlEnv: "LLAMACPP_BASE_URL", defaultModel: "local-model", local: true },
|
|
12
|
+
{ id: "custom", label: "Custom OpenAI-compatible", baseURL: "", urlEnv: "NOU_BASE_URL", keyEnv: "NOU_API_KEY", defaultModel: "default", local: true },
|
|
13
|
+
];
|
|
14
|
+
function toOpenAI(system, messages) {
|
|
15
|
+
const out = [{ role: "system", content: system }];
|
|
16
|
+
for (const m of messages) {
|
|
17
|
+
if (m.role === "user")
|
|
18
|
+
out.push({ role: "user", content: m.content });
|
|
19
|
+
else if (m.role === "tool")
|
|
20
|
+
out.push({ role: "tool", tool_call_id: m.callId, content: m.content });
|
|
21
|
+
else {
|
|
22
|
+
out.push({
|
|
23
|
+
role: "assistant",
|
|
24
|
+
content: m.content || null,
|
|
25
|
+
...(m.toolCalls.length
|
|
26
|
+
? {
|
|
27
|
+
tool_calls: m.toolCalls.map((c) => ({
|
|
28
|
+
id: c.id,
|
|
29
|
+
type: "function",
|
|
30
|
+
function: { name: c.name, arguments: JSON.stringify(c.args) },
|
|
31
|
+
})),
|
|
32
|
+
}
|
|
33
|
+
: {}),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
export function openAICompatibleProvider(preset) {
|
|
40
|
+
const baseURL = (preset.urlEnv && process.env[preset.urlEnv]) || preset.baseURL;
|
|
41
|
+
const apiKey = (preset.keyEnv && process.env[preset.keyEnv]) || (preset.local ? "not-needed" : "");
|
|
42
|
+
let client = null;
|
|
43
|
+
const getClient = () => (client ??= new OpenAI({ baseURL, apiKey }));
|
|
44
|
+
return {
|
|
45
|
+
id: preset.id,
|
|
46
|
+
label: preset.label,
|
|
47
|
+
local: preset.local,
|
|
48
|
+
defaultModel: preset.defaultModel,
|
|
49
|
+
unavailable() {
|
|
50
|
+
if (!baseURL)
|
|
51
|
+
return `set ${preset.urlEnv}`;
|
|
52
|
+
if (!apiKey)
|
|
53
|
+
return `set ${preset.keyEnv}`;
|
|
54
|
+
return null;
|
|
55
|
+
},
|
|
56
|
+
async chat({ model, system, messages, tools, onText, signal }) {
|
|
57
|
+
const stream = await getClient().chat.completions.create({
|
|
58
|
+
model,
|
|
59
|
+
messages: toOpenAI(system, messages),
|
|
60
|
+
...(tools.length ? { tools: tools.map((t) => ({ type: "function", function: t })) } : {}),
|
|
61
|
+
stream: true,
|
|
62
|
+
}, signal ? { signal } : {});
|
|
63
|
+
let text = "";
|
|
64
|
+
let finish = null;
|
|
65
|
+
// Tool calls stream in fragments keyed by index.
|
|
66
|
+
const partial = new Map();
|
|
67
|
+
for await (const chunk of stream) {
|
|
68
|
+
const choice = chunk.choices[0];
|
|
69
|
+
if (!choice)
|
|
70
|
+
continue;
|
|
71
|
+
if (choice.delta.content) {
|
|
72
|
+
text += choice.delta.content;
|
|
73
|
+
onText(choice.delta.content);
|
|
74
|
+
}
|
|
75
|
+
for (const delta of choice.delta.tool_calls ?? []) {
|
|
76
|
+
const call = partial.get(delta.index) ?? { id: "", name: "", args: "" };
|
|
77
|
+
if (delta.id)
|
|
78
|
+
call.id = delta.id;
|
|
79
|
+
if (delta.function?.name)
|
|
80
|
+
call.name += delta.function.name;
|
|
81
|
+
if (delta.function?.arguments)
|
|
82
|
+
call.args += delta.function.arguments;
|
|
83
|
+
partial.set(delta.index, call);
|
|
84
|
+
}
|
|
85
|
+
if (choice.finish_reason)
|
|
86
|
+
finish = choice.finish_reason;
|
|
87
|
+
}
|
|
88
|
+
const toolCalls = [...partial.values()].map((c) => {
|
|
89
|
+
let args;
|
|
90
|
+
try {
|
|
91
|
+
args = c.args ? JSON.parse(c.args) : {};
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
args = { __invalid_json: c.args };
|
|
95
|
+
}
|
|
96
|
+
return { id: c.id || newCallId(), name: c.name, args };
|
|
97
|
+
});
|
|
98
|
+
return { text, toolCalls, ...(finish === "length" && toolCalls.length ? { stop: "max_tokens" } : {}) };
|
|
99
|
+
},
|
|
100
|
+
async listModels() {
|
|
101
|
+
const models = [];
|
|
102
|
+
for await (const m of getClient().models.list())
|
|
103
|
+
models.push(m.id);
|
|
104
|
+
return models.sort();
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|