godspeed-agent 0.1.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/LICENSE +21 -0
- package/README.md +99 -0
- package/bin/godspeed +2 -0
- package/package.json +32 -0
- package/src/cli.ts +25 -0
- package/src/commands/agent.ts +81 -0
- package/src/commands/chat.ts +65 -0
- package/src/commands/models.ts +49 -0
- package/src/commands/providers.ts +83 -0
- package/src/commands/tools.ts +40 -0
- package/src/commands/tui.tsx +24 -0
- package/src/lib/agent.ts +183 -0
- package/src/lib/agentMemory.ts +69 -0
- package/src/lib/config.ts +55 -0
- package/src/lib/history.ts +50 -0
- package/src/lib/llm.ts +86 -0
- package/src/lib/providers.ts +45 -0
- package/src/providers/gemini.ts +91 -0
- package/src/providers/index.ts +24 -0
- package/src/providers/ollama.ts +84 -0
- package/src/providers/openrouter.ts +97 -0
- package/src/providers/types.ts +18 -0
- package/src/tools/labels.ts +38 -0
- package/src/tools/metadata.ts +102 -0
- package/src/tools/prompt.ts +37 -0
- package/src/tools/registry.ts +63 -0
- package/src/tools/tools.ts +179 -0
- package/src/tools/workspace.ts +22 -0
- package/src/tui/App.tsx +377 -0
package/src/lib/agent.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { askLLMWithMessages, type ChatMessage } from "./llm.js";
|
|
2
|
+
import { executeTool, type ToolAction } from "../tools/registry.js";
|
|
3
|
+
import { buildSystemPrompt } from "../tools/prompt.js";
|
|
4
|
+
import ora from "ora";
|
|
5
|
+
|
|
6
|
+
type AgentAction = { action: "FINAL"; answer: string } | ToolAction;
|
|
7
|
+
|
|
8
|
+
export type AgentStep = {
|
|
9
|
+
step: number;
|
|
10
|
+
action: string;
|
|
11
|
+
path?: string;
|
|
12
|
+
command?: string;
|
|
13
|
+
pattern?: string;
|
|
14
|
+
query?: string;
|
|
15
|
+
from?: string;
|
|
16
|
+
to?: string;
|
|
17
|
+
status: "started" | "completed" | "failed";
|
|
18
|
+
error?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type RunAgentOptions = {
|
|
22
|
+
onStep?: (step: AgentStep) => void;
|
|
23
|
+
showSpinner?: boolean;
|
|
24
|
+
confirmTool?: (action: ToolAction) => Promise<boolean>;
|
|
25
|
+
memory?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function extractJson(text: string): AgentAction {
|
|
29
|
+
const fencedJsonMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
|
|
30
|
+
|
|
31
|
+
if (fencedJsonMatch?.[1]) {
|
|
32
|
+
return JSON.parse(fencedJsonMatch[1]);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const firstBrace = text.indexOf("{");
|
|
36
|
+
const lastBrace = text.lastIndexOf("}");
|
|
37
|
+
|
|
38
|
+
if (firstBrace !== -1 && lastBrace !== -1) {
|
|
39
|
+
const jsonCandidate = text.slice(firstBrace, lastBrace + 1);
|
|
40
|
+
return JSON.parse(jsonCandidate);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return JSON.parse(text);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const systemPrompt = buildSystemPrompt();
|
|
47
|
+
|
|
48
|
+
export async function runAgent(prompt: string, options: RunAgentOptions = {}) {
|
|
49
|
+
const messages: ChatMessage[] = [
|
|
50
|
+
{
|
|
51
|
+
role: "user",
|
|
52
|
+
text: `${systemPrompt}
|
|
53
|
+
|
|
54
|
+
Previous summarized context:
|
|
55
|
+
${options.memory || "No previous context."}
|
|
56
|
+
|
|
57
|
+
User request:
|
|
58
|
+
${prompt}`,
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const maxSteps = 10;
|
|
63
|
+
const showSpinner = options.showSpinner ?? false;
|
|
64
|
+
|
|
65
|
+
for (let step = 0; step < maxSteps; step++) {
|
|
66
|
+
const raw = await askLLMWithMessages(messages);
|
|
67
|
+
|
|
68
|
+
let action: AgentAction;
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
action = extractJson(raw);
|
|
72
|
+
} catch {
|
|
73
|
+
messages.push({
|
|
74
|
+
role: "user",
|
|
75
|
+
text: `
|
|
76
|
+
You did not return valid JSON.
|
|
77
|
+
|
|
78
|
+
Return ONLY valid JSON.
|
|
79
|
+
Do not include explanations.
|
|
80
|
+
Do not include markdown.
|
|
81
|
+
`,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const stepInfo = {
|
|
88
|
+
step: step + 1,
|
|
89
|
+
action: action.action,
|
|
90
|
+
path: "path" in action ? action.path : undefined,
|
|
91
|
+
command: "command" in action ? action.command : undefined,
|
|
92
|
+
pattern: "pattern" in action ? action.pattern : undefined,
|
|
93
|
+
query: "query" in action ? action.query : undefined,
|
|
94
|
+
from: "from" in action ? action.from : undefined,
|
|
95
|
+
to: "to" in action ? action.to : undefined,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
messages.push({
|
|
99
|
+
role: "model",
|
|
100
|
+
text: JSON.stringify(action),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (action.action === "FINAL") {
|
|
104
|
+
options.onStep?.({
|
|
105
|
+
step: step + 1,
|
|
106
|
+
action: "FINAL",
|
|
107
|
+
status: "completed",
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return action.answer;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
options.onStep?.({
|
|
114
|
+
...stepInfo,
|
|
115
|
+
status: "started",
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const needsConfirmation =
|
|
119
|
+
action.action === "WRITE_FILE" ||
|
|
120
|
+
action.action === "EDIT_FILE" ||
|
|
121
|
+
action.action === "DELETE_FILE" ||
|
|
122
|
+
action.action === "CREATE_DIRECTORY" ||
|
|
123
|
+
action.action === "MOVE_FILE" ||
|
|
124
|
+
action.action === "COPY_FILE" ||
|
|
125
|
+
action.action === "RUN_COMMAND";
|
|
126
|
+
|
|
127
|
+
if (needsConfirmation && options.confirmTool) {
|
|
128
|
+
const approved = await options.confirmTool(action);
|
|
129
|
+
|
|
130
|
+
if (!approved) {
|
|
131
|
+
options.onStep?.({
|
|
132
|
+
...stepInfo,
|
|
133
|
+
status: "failed",
|
|
134
|
+
error: "Denied by user",
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
messages.push({
|
|
138
|
+
role: "user",
|
|
139
|
+
text: `Tool denied by user: ${action.action}`,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const spinner = showSpinner
|
|
147
|
+
? ora(`Running ${action.action}...`).start()
|
|
148
|
+
: null;
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const toolResult = await executeTool(action);
|
|
152
|
+
|
|
153
|
+
spinner?.succeed(`${action.action} completed`);
|
|
154
|
+
|
|
155
|
+
options.onStep?.({
|
|
156
|
+
...stepInfo,
|
|
157
|
+
status: "completed",
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
messages.push({
|
|
161
|
+
role: "user",
|
|
162
|
+
text: `Tool result for ${action.action}:\n${toolResult}`,
|
|
163
|
+
});
|
|
164
|
+
} catch (err) {
|
|
165
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
166
|
+
|
|
167
|
+
spinner?.fail(`${action.action} failed`);
|
|
168
|
+
|
|
169
|
+
options.onStep?.({
|
|
170
|
+
...stepInfo,
|
|
171
|
+
status: "failed",
|
|
172
|
+
error: errorMessage,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
messages.push({
|
|
176
|
+
role: "user",
|
|
177
|
+
text: `Tool error for ${action.action}:\n${errorMessage}`,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return "Agent stopped: max steps reached.";
|
|
183
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { askLLM } from "./llm.js";
|
|
5
|
+
|
|
6
|
+
const MEMORY_DIR = path.join(os.homedir(), ".tuistarter", "agent-memory");
|
|
7
|
+
|
|
8
|
+
function ensureMemoryDir() {
|
|
9
|
+
if (!fs.existsSync(MEMORY_DIR)) {
|
|
10
|
+
fs.mkdirSync(MEMORY_DIR, { recursive: true });
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getMemoryFile(sessionName = "default") {
|
|
15
|
+
ensureMemoryDir();
|
|
16
|
+
|
|
17
|
+
const safeName = sessionName.replace(/[^a-zA-Z0-9-_]/g, "_");
|
|
18
|
+
|
|
19
|
+
return path.join(MEMORY_DIR, `${safeName}.txt`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function loadAgentMemory(sessionName = "default") {
|
|
23
|
+
const file = getMemoryFile(sessionName);
|
|
24
|
+
|
|
25
|
+
if (!fs.existsSync(file)) return "";
|
|
26
|
+
|
|
27
|
+
return fs.readFileSync(file, "utf-8");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function saveAgentMemory(sessionName: string, memory: string) {
|
|
31
|
+
const file = getMemoryFile(sessionName);
|
|
32
|
+
fs.writeFileSync(file, memory, "utf-8");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function clearAgentMemory(sessionName = "default") {
|
|
36
|
+
const file = getMemoryFile(sessionName);
|
|
37
|
+
|
|
38
|
+
if (fs.existsSync(file)) {
|
|
39
|
+
fs.unlinkSync(file);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function updateAgentMemory(params: {
|
|
44
|
+
previousMemory: string;
|
|
45
|
+
userPrompt: string;
|
|
46
|
+
finalAnswer: string;
|
|
47
|
+
}) {
|
|
48
|
+
const prompt = `
|
|
49
|
+
Update this agent memory.
|
|
50
|
+
Rules:
|
|
51
|
+
- Keep it short.
|
|
52
|
+
- Store useful facts only.
|
|
53
|
+
- Do NOT include tool JSON.
|
|
54
|
+
- Do NOT include raw tool results.
|
|
55
|
+
- Do NOT include hidden reasoning.
|
|
56
|
+
- Do NOT include repetitive chat.
|
|
57
|
+
- Prefer bullet points.
|
|
58
|
+
- Max 10 bullets.
|
|
59
|
+
Previous memory:
|
|
60
|
+
${params.previousMemory || "None"}
|
|
61
|
+
Latest user request:
|
|
62
|
+
${params.userPrompt}
|
|
63
|
+
Latest assistant final answer:
|
|
64
|
+
${params.finalAnswer}
|
|
65
|
+
Return updated memory only.
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
return await askLLM(prompt);
|
|
69
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
|
|
5
|
+
export type ProviderConfig = {
|
|
6
|
+
apiKey?: string;
|
|
7
|
+
baseUrl?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type AppConfig = {
|
|
11
|
+
activeProvider?: string;
|
|
12
|
+
activeModel?: string;
|
|
13
|
+
providers: Record<string, ProviderConfig>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const CONFIG_DIR = path.join(os.homedir(), ".tuistarter");
|
|
17
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
18
|
+
|
|
19
|
+
const defaultConfig: AppConfig = {
|
|
20
|
+
activeProvider: undefined,
|
|
21
|
+
providers: {},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function ensureConfigDir() {
|
|
25
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
26
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function loadConfig(): AppConfig {
|
|
31
|
+
ensureConfigDir();
|
|
32
|
+
|
|
33
|
+
if (!fs.existsSync(CONFIG_FILE)) {
|
|
34
|
+
saveConfig(defaultConfig);
|
|
35
|
+
return defaultConfig;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
|
|
39
|
+
|
|
40
|
+
if (!raw.trim()) {
|
|
41
|
+
saveConfig(defaultConfig);
|
|
42
|
+
return defaultConfig;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return JSON.parse(raw) as AppConfig;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function saveConfig(config: AppConfig) {
|
|
49
|
+
ensureConfigDir();
|
|
50
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function getConfigPath() {
|
|
54
|
+
return CONFIG_FILE;
|
|
55
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import type { ChatMessage } from "./llm.js";
|
|
5
|
+
|
|
6
|
+
const HISTORY_DIR = path.join(os.homedir(), ".tuistarter", "history");
|
|
7
|
+
|
|
8
|
+
function ensureHistoryDir() {
|
|
9
|
+
if (!fs.existsSync(HISTORY_DIR)) {
|
|
10
|
+
fs.mkdirSync(HISTORY_DIR, { recursive: true });
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getHistoryFile(sessionName = "default") {
|
|
15
|
+
ensureHistoryDir();
|
|
16
|
+
|
|
17
|
+
const safeName = sessionName.replace(/[^a-zA-Z0-9-_]/g, "_");
|
|
18
|
+
|
|
19
|
+
return path.join(HISTORY_DIR, `${safeName}.json`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function loadHistory(sessionName = "default"): ChatMessage[] {
|
|
23
|
+
const file = getHistoryFile(sessionName);
|
|
24
|
+
|
|
25
|
+
if (!fs.existsSync(file)) {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const raw = fs.readFileSync(file, "utf-8");
|
|
30
|
+
|
|
31
|
+
if (!raw.trim()) {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return JSON.parse(raw) as ChatMessage[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function saveHistory(sessionName: string, messages: ChatMessage[]) {
|
|
39
|
+
const file = getHistoryFile(sessionName);
|
|
40
|
+
|
|
41
|
+
fs.writeFileSync(file, JSON.stringify(messages, null, 2));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function clearHistory(sessionName = "default") {
|
|
45
|
+
const file = getHistoryFile(sessionName);
|
|
46
|
+
|
|
47
|
+
if (fs.existsSync(file)) {
|
|
48
|
+
fs.unlinkSync(file);
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/lib/llm.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { loadConfig } from "./config.js";
|
|
2
|
+
import { getProvider } from "../providers/index.js";
|
|
3
|
+
|
|
4
|
+
export type ChatMessage = {
|
|
5
|
+
role: "user" | "model";
|
|
6
|
+
text: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
function getActiveProviderConfig() {
|
|
10
|
+
const config = loadConfig();
|
|
11
|
+
|
|
12
|
+
if (!config.activeProvider) {
|
|
13
|
+
throw new Error(
|
|
14
|
+
"No active provider. Run: tuistarter providers login -p gemini -k YOUR_KEY",
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (!config.activeModel) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
"No active model. Run: tuistarter models set gemini-2.5-flash",
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const providerConfig = config.providers[config.activeProvider];
|
|
25
|
+
|
|
26
|
+
if (!providerConfig) {
|
|
27
|
+
throw new Error(`Provider config not found: ${config.activeProvider}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const provider = getProvider(config.activeProvider);
|
|
31
|
+
|
|
32
|
+
if (config.activeProvider !== "ollama" && !providerConfig.apiKey) {
|
|
33
|
+
throw new Error(`No API key found for provider: ${config.activeProvider}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
provider,
|
|
38
|
+
apiKey: providerConfig.apiKey ?? "",
|
|
39
|
+
model: config.activeModel,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function askLLM(prompt: string) {
|
|
44
|
+
return askLLMWithMessages([
|
|
45
|
+
{
|
|
46
|
+
role: "user",
|
|
47
|
+
text: prompt,
|
|
48
|
+
},
|
|
49
|
+
]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function askLLMWithMessages(messages: ChatMessage[]) {
|
|
53
|
+
const { provider, apiKey, model } = getActiveProviderConfig();
|
|
54
|
+
|
|
55
|
+
return provider.generate({
|
|
56
|
+
apiKey,
|
|
57
|
+
model,
|
|
58
|
+
messages,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function streamLLM(prompt: string) {
|
|
63
|
+
await streamLLMToCallback(prompt, (token) => {
|
|
64
|
+
process.stdout.write(token);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
process.stdout.write("\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function streamLLMToCallback(
|
|
71
|
+
prompt: string,
|
|
72
|
+
onToken: (token: string) => void,
|
|
73
|
+
) {
|
|
74
|
+
const { provider, apiKey, model } = getActiveProviderConfig();
|
|
75
|
+
|
|
76
|
+
if (!provider.stream) {
|
|
77
|
+
throw new Error(`Streaming not supported for provider: ${provider.name}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
await provider.stream({
|
|
81
|
+
apiKey,
|
|
82
|
+
model,
|
|
83
|
+
prompt,
|
|
84
|
+
onToken,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export const SUPPORTED_PROVIDERS = [
|
|
2
|
+
{
|
|
3
|
+
name: "gemini",
|
|
4
|
+
models: ["gemini-2.5-flash", "gemini-2.5-pro", "gemini-2.0-flash"],
|
|
5
|
+
},
|
|
6
|
+
{
|
|
7
|
+
name: "openrouter",
|
|
8
|
+
models: [
|
|
9
|
+
// Free router
|
|
10
|
+
"openrouter/free",
|
|
11
|
+
// Coding
|
|
12
|
+
"poolside/laguna-m1:free",
|
|
13
|
+
"poolside/laguna-xs.2:free",
|
|
14
|
+
// Qwen
|
|
15
|
+
"qwen/qwen3-235b-a22b:free",
|
|
16
|
+
// DeepSeek
|
|
17
|
+
"deepseek/deepseek-r1:free",
|
|
18
|
+
"deepseek/deepseek-chat-v3-0324:free",
|
|
19
|
+
// Llama
|
|
20
|
+
"meta-llama/llama-4-maverick:free",
|
|
21
|
+
// OpenAI OSS
|
|
22
|
+
"openai/gpt-oss-120b:free",
|
|
23
|
+
"openai/gpt-oss-20b:free",
|
|
24
|
+
// NVIDIA
|
|
25
|
+
"nvidia/nemotron-3-super:free",
|
|
26
|
+
"nvidia/nemotron-3-nano-30b-a3b:free",
|
|
27
|
+
// GLM
|
|
28
|
+
"z-ai/glm-4.5-air:free",
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: "ollama",
|
|
33
|
+
models: [
|
|
34
|
+
"qwen3.5:2b",
|
|
35
|
+
"qwen3.5:4b",
|
|
36
|
+
"gemma3:4b",
|
|
37
|
+
"deepseek-r1:7b",
|
|
38
|
+
"qwen2.5-coder:1.5b-instruct-q4_K_M",
|
|
39
|
+
"qwen3.5:2b-q4_K_M",
|
|
40
|
+
"qwen2.5-coder:3b-instruct-q4_K_M",
|
|
41
|
+
"qwen3.5:4b-q4_K_M",
|
|
42
|
+
"qwen2.5-coder:7b-instruct-q4_K_M",
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
];
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { LLMProvider } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export const geminiProvider: LLMProvider = {
|
|
4
|
+
name: "gemini",
|
|
5
|
+
|
|
6
|
+
async generate({ apiKey, model, messages }) {
|
|
7
|
+
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`;
|
|
8
|
+
|
|
9
|
+
const res = await fetch(url, {
|
|
10
|
+
method: "POST",
|
|
11
|
+
headers: {
|
|
12
|
+
"x-goog-api-key": apiKey,
|
|
13
|
+
"Content-Type": "application/json",
|
|
14
|
+
},
|
|
15
|
+
body: JSON.stringify({
|
|
16
|
+
contents: messages.map((message) => ({
|
|
17
|
+
role: message.role,
|
|
18
|
+
parts: [{ text: message.text }],
|
|
19
|
+
})),
|
|
20
|
+
}),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
if (!res.ok) {
|
|
24
|
+
throw new Error(await res.text());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const data: any = await res.json();
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
data?.candidates?.[0]?.content?.parts?.[0]?.text ??
|
|
31
|
+
"No response text found."
|
|
32
|
+
);
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
async stream({ apiKey, model, prompt, onToken }) {
|
|
36
|
+
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:streamGenerateContent?alt=sse`;
|
|
37
|
+
|
|
38
|
+
const res = await fetch(url, {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: {
|
|
41
|
+
"x-goog-api-key": apiKey,
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
},
|
|
44
|
+
body: JSON.stringify({
|
|
45
|
+
contents: [
|
|
46
|
+
{
|
|
47
|
+
role: "user",
|
|
48
|
+
parts: [{ text: prompt }],
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
}),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (!res.ok || !res.body) {
|
|
55
|
+
throw new Error(await res.text());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const reader = res.body.getReader();
|
|
59
|
+
const decoder = new TextDecoder();
|
|
60
|
+
|
|
61
|
+
let buffer = "";
|
|
62
|
+
|
|
63
|
+
while (true) {
|
|
64
|
+
const { done, value } = await reader.read();
|
|
65
|
+
|
|
66
|
+
if (done) break;
|
|
67
|
+
|
|
68
|
+
buffer += decoder.decode(value, { stream: true });
|
|
69
|
+
|
|
70
|
+
const lines = buffer.split("\n");
|
|
71
|
+
buffer = lines.pop() ?? "";
|
|
72
|
+
|
|
73
|
+
for (const line of lines) {
|
|
74
|
+
const trimmed = line.trim();
|
|
75
|
+
|
|
76
|
+
if (!trimmed.startsWith("data: ")) continue;
|
|
77
|
+
|
|
78
|
+
const jsonText = trimmed.replace("data: ", "");
|
|
79
|
+
|
|
80
|
+
if (jsonText === "[DONE]") continue;
|
|
81
|
+
|
|
82
|
+
const data = JSON.parse(jsonText);
|
|
83
|
+
const text = data?.candidates?.[0]?.content?.parts?.[0]?.text;
|
|
84
|
+
|
|
85
|
+
if (text) {
|
|
86
|
+
onToken(text);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { geminiProvider } from "./gemini.js";
|
|
2
|
+
import { ollamaProvider } from "./ollama.js";
|
|
3
|
+
import { openrouterProvider } from "./openrouter.js";
|
|
4
|
+
import type { LLMProvider } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export const providers: Record<string, LLMProvider> = {
|
|
7
|
+
gemini: geminiProvider,
|
|
8
|
+
openrouter: openrouterProvider,
|
|
9
|
+
ollama: ollamaProvider,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function getProvider(name: string) {
|
|
13
|
+
const provider = providers[name];
|
|
14
|
+
|
|
15
|
+
if (!provider) {
|
|
16
|
+
throw new Error(`Provider not implemented yet: ${name}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return provider;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getProviderNames() {
|
|
23
|
+
return Object.keys(providers);
|
|
24
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { LLMProvider } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export const ollamaProvider: LLMProvider = {
|
|
4
|
+
name: "ollama",
|
|
5
|
+
|
|
6
|
+
async generate({ model, messages }) {
|
|
7
|
+
const res = await fetch(
|
|
8
|
+
"http://localhost:11434/api/chat",
|
|
9
|
+
{
|
|
10
|
+
method: "POST",
|
|
11
|
+
headers: {
|
|
12
|
+
"Content-Type": "application/json",
|
|
13
|
+
},
|
|
14
|
+
body: JSON.stringify({
|
|
15
|
+
model,
|
|
16
|
+
stream: false,
|
|
17
|
+
messages: messages.map((m) => ({
|
|
18
|
+
role: m.role === "model" ? "assistant" : "user",
|
|
19
|
+
content: m.text,
|
|
20
|
+
})),
|
|
21
|
+
}),
|
|
22
|
+
},
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
if (!res.ok) {
|
|
26
|
+
throw new Error(await res.text());
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const data: any = await res.json();
|
|
30
|
+
|
|
31
|
+
return data.message?.content ?? "";
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
async stream({ model, prompt, onToken }) {
|
|
35
|
+
const res = await fetch(
|
|
36
|
+
"http://localhost:11434/api/generate",
|
|
37
|
+
{
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: {
|
|
40
|
+
"Content-Type": "application/json",
|
|
41
|
+
},
|
|
42
|
+
body: JSON.stringify({
|
|
43
|
+
model,
|
|
44
|
+
prompt,
|
|
45
|
+
stream: true,
|
|
46
|
+
}),
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
if (!res.ok || !res.body) {
|
|
51
|
+
throw new Error(await res.text());
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const reader = res.body.getReader();
|
|
55
|
+
const decoder = new TextDecoder();
|
|
56
|
+
|
|
57
|
+
let buffer = "";
|
|
58
|
+
|
|
59
|
+
while (true) {
|
|
60
|
+
const { done, value } = await reader.read();
|
|
61
|
+
|
|
62
|
+
if (done) break;
|
|
63
|
+
|
|
64
|
+
buffer += decoder.decode(value, {
|
|
65
|
+
stream: true,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const lines = buffer.split("\n");
|
|
69
|
+
buffer = lines.pop() ?? "";
|
|
70
|
+
|
|
71
|
+
for (const line of lines) {
|
|
72
|
+
if (!line.trim()) continue;
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const data = JSON.parse(line);
|
|
76
|
+
|
|
77
|
+
if (data.response) {
|
|
78
|
+
onToken(data.response);
|
|
79
|
+
}
|
|
80
|
+
} catch {}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
};
|