@phren/agent 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-loop.js +328 -0
- package/dist/bin.js +3 -0
- package/dist/checkpoint.js +103 -0
- package/dist/commands.js +292 -0
- package/dist/config.js +139 -0
- package/dist/context/pruner.js +62 -0
- package/dist/context/token-counter.js +28 -0
- package/dist/cost.js +71 -0
- package/dist/index.js +284 -0
- package/dist/mcp-client.js +168 -0
- package/dist/memory/anti-patterns.js +69 -0
- package/dist/memory/auto-capture.js +72 -0
- package/dist/memory/context-flush.js +24 -0
- package/dist/memory/context.js +170 -0
- package/dist/memory/error-recovery.js +58 -0
- package/dist/memory/project-context.js +77 -0
- package/dist/memory/session.js +100 -0
- package/dist/multi/agent-colors.js +41 -0
- package/dist/multi/child-entry.js +173 -0
- package/dist/multi/coordinator.js +263 -0
- package/dist/multi/diff-renderer.js +175 -0
- package/dist/multi/markdown.js +96 -0
- package/dist/multi/presets.js +107 -0
- package/dist/multi/progress.js +32 -0
- package/dist/multi/spawner.js +219 -0
- package/dist/multi/tui-multi.js +626 -0
- package/dist/multi/types.js +7 -0
- package/dist/permissions/allowlist.js +61 -0
- package/dist/permissions/checker.js +111 -0
- package/dist/permissions/prompt.js +190 -0
- package/dist/permissions/sandbox.js +95 -0
- package/dist/permissions/shell-safety.js +74 -0
- package/dist/permissions/types.js +2 -0
- package/dist/plan.js +38 -0
- package/dist/providers/anthropic.js +170 -0
- package/dist/providers/codex-auth.js +197 -0
- package/dist/providers/codex.js +265 -0
- package/dist/providers/ollama.js +142 -0
- package/dist/providers/openai-compat.js +163 -0
- package/dist/providers/openrouter.js +116 -0
- package/dist/providers/resolve.js +39 -0
- package/dist/providers/retry.js +55 -0
- package/dist/providers/types.js +2 -0
- package/dist/repl.js +180 -0
- package/dist/spinner.js +46 -0
- package/dist/system-prompt.js +31 -0
- package/dist/tools/edit-file.js +31 -0
- package/dist/tools/git.js +98 -0
- package/dist/tools/glob.js +65 -0
- package/dist/tools/grep.js +108 -0
- package/dist/tools/lint-test.js +76 -0
- package/dist/tools/phren-finding.js +35 -0
- package/dist/tools/phren-search.js +44 -0
- package/dist/tools/phren-tasks.js +71 -0
- package/dist/tools/read-file.js +44 -0
- package/dist/tools/registry.js +46 -0
- package/dist/tools/shell.js +48 -0
- package/dist/tools/types.js +2 -0
- package/dist/tools/write-file.js +27 -0
- package/dist/tui.js +451 -0
- package/package.json +39 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex OAuth PKCE flow — authenticate with your ChatGPT subscription.
|
|
3
|
+
* Same flow as Codex CLI, no middleman.
|
|
4
|
+
*/
|
|
5
|
+
import * as crypto from "crypto";
|
|
6
|
+
import * as fs from "fs";
|
|
7
|
+
import * as path from "path";
|
|
8
|
+
import * as http from "http";
|
|
9
|
+
import * as os from "os";
|
|
10
|
+
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
11
|
+
const AUTH_URL = "https://auth.openai.com/oauth/authorize";
|
|
12
|
+
const TOKEN_URL = "https://auth.openai.com/oauth/token";
|
|
13
|
+
const REDIRECT_URI = "http://localhost:1455/auth/callback";
|
|
14
|
+
const CALLBACK_PORT = 1455;
|
|
15
|
+
const SCOPES = "openid profile email offline_access";
|
|
16
|
+
function tokenPath() {
|
|
17
|
+
const dir = path.join(os.homedir(), ".phren-agent");
|
|
18
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
19
|
+
return path.join(dir, "codex-token.json");
|
|
20
|
+
}
|
|
21
|
+
function generatePKCE() {
|
|
22
|
+
const bytes = crypto.randomBytes(32);
|
|
23
|
+
const codeVerifier = bytes.toString("base64url");
|
|
24
|
+
const hash = crypto.createHash("sha256").update(codeVerifier).digest();
|
|
25
|
+
const codeChallenge = hash.toString("base64url");
|
|
26
|
+
return { codeVerifier, codeChallenge };
|
|
27
|
+
}
|
|
28
|
+
function buildAuthUrl(codeChallenge, state) {
|
|
29
|
+
const params = new URLSearchParams({
|
|
30
|
+
response_type: "code",
|
|
31
|
+
client_id: CLIENT_ID,
|
|
32
|
+
redirect_uri: REDIRECT_URI,
|
|
33
|
+
scope: SCOPES,
|
|
34
|
+
code_challenge: codeChallenge,
|
|
35
|
+
code_challenge_method: "S256",
|
|
36
|
+
state,
|
|
37
|
+
id_token_add_organizations: "true",
|
|
38
|
+
codex_cli_simplified_flow: "true",
|
|
39
|
+
});
|
|
40
|
+
return `${AUTH_URL}?${params.toString()}`;
|
|
41
|
+
}
|
|
42
|
+
async function exchangeCode(code, codeVerifier) {
|
|
43
|
+
const res = await fetch(TOKEN_URL, {
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
46
|
+
body: new URLSearchParams({
|
|
47
|
+
grant_type: "authorization_code",
|
|
48
|
+
code,
|
|
49
|
+
redirect_uri: REDIRECT_URI,
|
|
50
|
+
client_id: CLIENT_ID,
|
|
51
|
+
code_verifier: codeVerifier,
|
|
52
|
+
}).toString(),
|
|
53
|
+
});
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
const text = await res.text();
|
|
56
|
+
throw new Error(`Token exchange failed (${res.status}): ${text}`);
|
|
57
|
+
}
|
|
58
|
+
const data = await res.json();
|
|
59
|
+
const expiresIn = data.expires_in || 3600;
|
|
60
|
+
// Extract account_id from JWT access token
|
|
61
|
+
let accountId;
|
|
62
|
+
try {
|
|
63
|
+
const payload = JSON.parse(Buffer.from(data.access_token.split(".")[1], "base64url").toString());
|
|
64
|
+
accountId = payload.sub || payload.account_id;
|
|
65
|
+
}
|
|
66
|
+
catch { /* skip */ }
|
|
67
|
+
return {
|
|
68
|
+
access_token: data.access_token,
|
|
69
|
+
refresh_token: data.refresh_token,
|
|
70
|
+
expires_at: Date.now() + expiresIn * 1000,
|
|
71
|
+
account_id: accountId,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
async function refreshToken(refreshTok) {
|
|
75
|
+
const res = await fetch(TOKEN_URL, {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
78
|
+
body: new URLSearchParams({
|
|
79
|
+
grant_type: "refresh_token",
|
|
80
|
+
refresh_token: refreshTok,
|
|
81
|
+
client_id: CLIENT_ID,
|
|
82
|
+
}).toString(),
|
|
83
|
+
});
|
|
84
|
+
if (!res.ok) {
|
|
85
|
+
const text = await res.text();
|
|
86
|
+
throw new Error(`Token refresh failed (${res.status}): ${text}`);
|
|
87
|
+
}
|
|
88
|
+
const data = await res.json();
|
|
89
|
+
const expiresIn = data.expires_in || 3600;
|
|
90
|
+
return {
|
|
91
|
+
access_token: data.access_token,
|
|
92
|
+
refresh_token: data.refresh_token || refreshTok,
|
|
93
|
+
expires_at: Date.now() + expiresIn * 1000,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/** Interactive OAuth login — opens browser, waits for callback. */
|
|
97
|
+
export async function codexLogin() {
|
|
98
|
+
const { codeVerifier, codeChallenge } = generatePKCE();
|
|
99
|
+
const state = crypto.randomBytes(16).toString("hex");
|
|
100
|
+
const authUrl = buildAuthUrl(codeChallenge, state);
|
|
101
|
+
console.log("Opening browser for Codex login...");
|
|
102
|
+
console.log(`If it doesn't open, visit:\n${authUrl}\n`);
|
|
103
|
+
// Open browser
|
|
104
|
+
const openCmd = process.platform === "darwin" ? "open"
|
|
105
|
+
: process.platform === "win32" ? "start"
|
|
106
|
+
: "xdg-open";
|
|
107
|
+
try {
|
|
108
|
+
const { execFileSync } = await import("child_process");
|
|
109
|
+
execFileSync(openCmd, [authUrl], { stdio: "ignore" });
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// Browser open failed — user has the URL printed above
|
|
113
|
+
}
|
|
114
|
+
// Start local callback server
|
|
115
|
+
const code = await new Promise((resolve, reject) => {
|
|
116
|
+
const server = http.createServer((req, res) => {
|
|
117
|
+
const url = new URL(req.url ?? "/", `http://localhost:${CALLBACK_PORT}`);
|
|
118
|
+
if (url.pathname !== "/auth/callback") {
|
|
119
|
+
res.writeHead(404);
|
|
120
|
+
res.end();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const returnedState = url.searchParams.get("state");
|
|
124
|
+
const returnedCode = url.searchParams.get("code");
|
|
125
|
+
const error = url.searchParams.get("error");
|
|
126
|
+
if (error) {
|
|
127
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
128
|
+
res.end(`<h2>Login failed: ${error}</h2><p>You can close this tab.</p>`);
|
|
129
|
+
server.close();
|
|
130
|
+
reject(new Error(`OAuth error: ${error}`));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (returnedState !== state) {
|
|
134
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
135
|
+
res.end("<h2>State mismatch — try again</h2>");
|
|
136
|
+
// Don't crash — just ignore stale callbacks and keep waiting
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
140
|
+
res.end("<h2>Login successful!</h2><p>You can close this tab and return to the terminal.</p>");
|
|
141
|
+
server.close();
|
|
142
|
+
resolve(returnedCode);
|
|
143
|
+
});
|
|
144
|
+
server.listen(CALLBACK_PORT, () => {
|
|
145
|
+
console.log(`Waiting for callback on port ${CALLBACK_PORT}...`);
|
|
146
|
+
});
|
|
147
|
+
server.on("error", (err) => {
|
|
148
|
+
reject(new Error(`Callback server failed: ${err.message}. Is port ${CALLBACK_PORT} in use?`));
|
|
149
|
+
});
|
|
150
|
+
// Timeout after 2 minutes
|
|
151
|
+
setTimeout(() => { server.close(); reject(new Error("Login timed out (2 min)")); }, 120_000);
|
|
152
|
+
});
|
|
153
|
+
// Exchange code for tokens
|
|
154
|
+
console.log("Exchanging code for tokens...");
|
|
155
|
+
const tokens = await exchangeCode(code, codeVerifier);
|
|
156
|
+
// Save tokens
|
|
157
|
+
fs.writeFileSync(tokenPath(), JSON.stringify(tokens, null, 2) + "\n", { mode: 0o600 });
|
|
158
|
+
console.log(`Logged in! Token saved to ${tokenPath()}`);
|
|
159
|
+
}
|
|
160
|
+
// Lock so concurrent callers share a single in-flight refresh
|
|
161
|
+
let refreshPromise = null;
|
|
162
|
+
/** Load stored token, auto-refresh if expiring within 5 minutes. */
|
|
163
|
+
export async function getAccessToken() {
|
|
164
|
+
const file = tokenPath();
|
|
165
|
+
if (!fs.existsSync(file)) {
|
|
166
|
+
throw new Error("Not logged in to Codex. Run: phren-agent auth login");
|
|
167
|
+
}
|
|
168
|
+
let tokens = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
169
|
+
// Refresh if expiring within 5 minutes
|
|
170
|
+
if (tokens.expires_at < Date.now() + 5 * 60 * 1000) {
|
|
171
|
+
if (!tokens.refresh_token) {
|
|
172
|
+
throw new Error("Token expired and no refresh token. Run: phren-agent auth login");
|
|
173
|
+
}
|
|
174
|
+
if (!refreshPromise) {
|
|
175
|
+
console.error("Refreshing Codex token...");
|
|
176
|
+
refreshPromise = refreshToken(tokens.refresh_token).finally(() => { refreshPromise = null; });
|
|
177
|
+
}
|
|
178
|
+
tokens = await refreshPromise;
|
|
179
|
+
fs.writeFileSync(file, JSON.stringify(tokens, null, 2) + "\n", { mode: 0o600 });
|
|
180
|
+
}
|
|
181
|
+
return { accessToken: tokens.access_token, accountId: tokens.account_id };
|
|
182
|
+
}
|
|
183
|
+
/** Check if user has a stored Codex token. */
|
|
184
|
+
export function hasCodexToken() {
|
|
185
|
+
return fs.existsSync(tokenPath());
|
|
186
|
+
}
|
|
187
|
+
/** Remove stored token. */
|
|
188
|
+
export function codexLogout() {
|
|
189
|
+
const file = tokenPath();
|
|
190
|
+
if (fs.existsSync(file)) {
|
|
191
|
+
fs.unlinkSync(file);
|
|
192
|
+
console.log("Logged out of Codex.");
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
console.log("Not logged in.");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { getAccessToken } from "./codex-auth.js";
|
|
2
|
+
const CODEX_API = "https://chatgpt.com/backend-api/codex/responses";
|
|
3
|
+
/** Convert our tool defs to Responses API tool format. */
|
|
4
|
+
function toResponsesTools(tools) {
|
|
5
|
+
return tools.map((t) => ({
|
|
6
|
+
type: "function",
|
|
7
|
+
name: t.name,
|
|
8
|
+
description: t.description,
|
|
9
|
+
parameters: t.input_schema,
|
|
10
|
+
}));
|
|
11
|
+
}
|
|
12
|
+
/** Convert our messages to Responses API input format. */
|
|
13
|
+
function toResponsesInput(system, messages) {
|
|
14
|
+
const input = [];
|
|
15
|
+
for (const msg of messages) {
|
|
16
|
+
if (msg.role === "user") {
|
|
17
|
+
if (typeof msg.content === "string") {
|
|
18
|
+
input.push({
|
|
19
|
+
type: "message",
|
|
20
|
+
role: "user",
|
|
21
|
+
content: [{ type: "input_text", text: msg.content }],
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
// tool_result blocks
|
|
26
|
+
for (const block of msg.content) {
|
|
27
|
+
if (block.type === "tool_result") {
|
|
28
|
+
input.push({
|
|
29
|
+
type: "function_call_output",
|
|
30
|
+
call_id: block.tool_use_id,
|
|
31
|
+
output: block.content,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
else if (block.type === "text") {
|
|
35
|
+
input.push({
|
|
36
|
+
type: "message",
|
|
37
|
+
role: "user",
|
|
38
|
+
content: [{ type: "input_text", text: block.text }],
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
else if (msg.role === "assistant") {
|
|
45
|
+
if (typeof msg.content === "string") {
|
|
46
|
+
input.push({
|
|
47
|
+
type: "message",
|
|
48
|
+
role: "assistant",
|
|
49
|
+
content: [{ type: "output_text", text: msg.content }],
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
for (const block of msg.content) {
|
|
54
|
+
if (block.type === "text") {
|
|
55
|
+
input.push({
|
|
56
|
+
type: "message",
|
|
57
|
+
role: "assistant",
|
|
58
|
+
content: [{ type: "output_text", text: block.text }],
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
else if (block.type === "tool_use") {
|
|
62
|
+
input.push({
|
|
63
|
+
type: "function_call",
|
|
64
|
+
call_id: block.id,
|
|
65
|
+
name: block.name,
|
|
66
|
+
arguments: JSON.stringify(block.input),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return input;
|
|
74
|
+
}
|
|
75
|
+
/** Parse non-streaming Responses API output into our ContentBlock format. */
|
|
76
|
+
function parseResponsesOutput(data) {
|
|
77
|
+
const output = data.output;
|
|
78
|
+
const content = [];
|
|
79
|
+
let hasToolUse = false;
|
|
80
|
+
if (output) {
|
|
81
|
+
for (const item of output) {
|
|
82
|
+
if (item.type === "message") {
|
|
83
|
+
const msgContent = item.content;
|
|
84
|
+
if (msgContent) {
|
|
85
|
+
for (const c of msgContent) {
|
|
86
|
+
if (c.type === "output_text" && c.text) {
|
|
87
|
+
content.push({ type: "text", text: c.text });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
else if (item.type === "function_call") {
|
|
93
|
+
hasToolUse = true;
|
|
94
|
+
content.push({
|
|
95
|
+
type: "tool_use",
|
|
96
|
+
id: item.call_id,
|
|
97
|
+
name: item.name,
|
|
98
|
+
input: JSON.parse(item.arguments),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const status = data.status;
|
|
104
|
+
const stop_reason = hasToolUse ? "tool_use"
|
|
105
|
+
: status === "incomplete" ? "max_tokens"
|
|
106
|
+
: "end_turn";
|
|
107
|
+
const usage = data.usage;
|
|
108
|
+
return {
|
|
109
|
+
content,
|
|
110
|
+
stop_reason,
|
|
111
|
+
usage: usage ? { input_tokens: usage.input_tokens ?? 0, output_tokens: usage.output_tokens ?? 0 } : undefined,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
export class CodexProvider {
|
|
115
|
+
name = "codex";
|
|
116
|
+
contextWindow = 128_000;
|
|
117
|
+
model;
|
|
118
|
+
constructor(model) {
|
|
119
|
+
this.model = model ?? "gpt-5.2-codex";
|
|
120
|
+
}
|
|
121
|
+
async chat(system, messages, tools) {
|
|
122
|
+
const { accessToken } = await getAccessToken();
|
|
123
|
+
const body = {
|
|
124
|
+
model: this.model,
|
|
125
|
+
instructions: system,
|
|
126
|
+
input: toResponsesInput(system, messages),
|
|
127
|
+
store: false,
|
|
128
|
+
stream: true,
|
|
129
|
+
};
|
|
130
|
+
if (tools.length > 0) {
|
|
131
|
+
body.tools = toResponsesTools(tools);
|
|
132
|
+
body.tool_choice = "auto";
|
|
133
|
+
}
|
|
134
|
+
const bodyStr = JSON.stringify(body);
|
|
135
|
+
const res = await fetch(CODEX_API, {
|
|
136
|
+
method: "POST",
|
|
137
|
+
headers: {
|
|
138
|
+
"Content-Type": "application/json",
|
|
139
|
+
Authorization: `Bearer ${accessToken}`,
|
|
140
|
+
},
|
|
141
|
+
body: bodyStr,
|
|
142
|
+
});
|
|
143
|
+
if (!res.ok) {
|
|
144
|
+
const text = await res.text();
|
|
145
|
+
throw new Error(`Codex API error ${res.status}: ${text}`);
|
|
146
|
+
}
|
|
147
|
+
// Stream is mandatory for Codex backend — consume it and collect the final response
|
|
148
|
+
if (!res.body)
|
|
149
|
+
throw new Error("Provider returned empty response body");
|
|
150
|
+
const reader = res.body.getReader();
|
|
151
|
+
const decoder = new TextDecoder();
|
|
152
|
+
let buffer = "";
|
|
153
|
+
let finalResponse = null;
|
|
154
|
+
while (true) {
|
|
155
|
+
const { done, value } = await reader.read();
|
|
156
|
+
if (done)
|
|
157
|
+
break;
|
|
158
|
+
buffer += decoder.decode(value, { stream: true });
|
|
159
|
+
const lines = buffer.split("\n");
|
|
160
|
+
buffer = lines.pop();
|
|
161
|
+
for (const line of lines) {
|
|
162
|
+
if (!line.startsWith("data: "))
|
|
163
|
+
continue;
|
|
164
|
+
const data = line.slice(6).trim();
|
|
165
|
+
if (data === "[DONE]")
|
|
166
|
+
continue;
|
|
167
|
+
try {
|
|
168
|
+
const event = JSON.parse(data);
|
|
169
|
+
if (event.type === "response.completed" && event.response) {
|
|
170
|
+
finalResponse = event.response;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch { /* skip */ }
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (finalResponse)
|
|
177
|
+
return parseResponsesOutput(finalResponse);
|
|
178
|
+
// No response.completed event received
|
|
179
|
+
throw new Error("Codex stream ended without response.completed event");
|
|
180
|
+
}
|
|
181
|
+
async *chatStream(system, messages, tools) {
|
|
182
|
+
const { accessToken } = await getAccessToken();
|
|
183
|
+
const body = {
|
|
184
|
+
model: this.model,
|
|
185
|
+
instructions: system,
|
|
186
|
+
input: toResponsesInput(system, messages),
|
|
187
|
+
store: false,
|
|
188
|
+
stream: true,
|
|
189
|
+
include: ["reasoning.encrypted_content"],
|
|
190
|
+
};
|
|
191
|
+
if (tools.length > 0) {
|
|
192
|
+
body.tools = toResponsesTools(tools);
|
|
193
|
+
body.tool_choice = "auto";
|
|
194
|
+
}
|
|
195
|
+
const res = await fetch(CODEX_API, {
|
|
196
|
+
method: "POST",
|
|
197
|
+
headers: {
|
|
198
|
+
"Content-Type": "application/json",
|
|
199
|
+
Authorization: `Bearer ${accessToken}`,
|
|
200
|
+
},
|
|
201
|
+
body: JSON.stringify(body),
|
|
202
|
+
});
|
|
203
|
+
if (!res.ok) {
|
|
204
|
+
const text = await res.text();
|
|
205
|
+
throw new Error(`Codex API error ${res.status}: ${text}`);
|
|
206
|
+
}
|
|
207
|
+
// Parse SSE stream
|
|
208
|
+
if (!res.body)
|
|
209
|
+
throw new Error("Provider returned empty response body");
|
|
210
|
+
const reader = res.body.getReader();
|
|
211
|
+
const decoder = new TextDecoder();
|
|
212
|
+
let buffer = "";
|
|
213
|
+
let activeToolCallId = "";
|
|
214
|
+
while (true) {
|
|
215
|
+
const { done, value } = await reader.read();
|
|
216
|
+
if (done)
|
|
217
|
+
break;
|
|
218
|
+
buffer += decoder.decode(value, { stream: true });
|
|
219
|
+
const lines = buffer.split("\n");
|
|
220
|
+
buffer = lines.pop();
|
|
221
|
+
for (const line of lines) {
|
|
222
|
+
if (!line.startsWith("data: "))
|
|
223
|
+
continue;
|
|
224
|
+
const data = line.slice(6).trim();
|
|
225
|
+
if (data === "[DONE]")
|
|
226
|
+
return;
|
|
227
|
+
let event;
|
|
228
|
+
try {
|
|
229
|
+
event = JSON.parse(data);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const type = event.type;
|
|
235
|
+
if (type === "response.output_text.delta") {
|
|
236
|
+
yield { type: "text_delta", text: event.delta };
|
|
237
|
+
}
|
|
238
|
+
else if (type === "response.output_item.added") {
|
|
239
|
+
if (event.item?.type === "function_call") {
|
|
240
|
+
const item = event.item;
|
|
241
|
+
activeToolCallId = item.call_id;
|
|
242
|
+
yield { type: "tool_use_start", id: activeToolCallId, name: item.name };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
else if (type === "response.function_call_arguments.delta") {
|
|
246
|
+
yield { type: "tool_use_delta", id: activeToolCallId, json: event.delta };
|
|
247
|
+
}
|
|
248
|
+
else if (type === "response.function_call_arguments.done") {
|
|
249
|
+
yield { type: "tool_use_end", id: activeToolCallId };
|
|
250
|
+
}
|
|
251
|
+
else if (type === "response.completed") {
|
|
252
|
+
const response = event.response;
|
|
253
|
+
const usage = response?.usage;
|
|
254
|
+
const output = response?.output;
|
|
255
|
+
const hasToolCalls = output?.some((o) => o.type === "function_call");
|
|
256
|
+
yield {
|
|
257
|
+
type: "done",
|
|
258
|
+
stop_reason: hasToolCalls ? "tool_use" : "end_turn",
|
|
259
|
+
usage: usage ? { input_tokens: usage.input_tokens ?? 0, output_tokens: usage.output_tokens ?? 0 } : undefined,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/** Convert Anthropic tool defs to OpenAI function format (Ollama uses OpenAI-compat). */
|
|
2
|
+
function toOllamaTools(tools) {
|
|
3
|
+
return tools.map((t) => ({
|
|
4
|
+
type: "function",
|
|
5
|
+
function: { name: t.name, description: t.description, parameters: t.input_schema },
|
|
6
|
+
}));
|
|
7
|
+
}
|
|
8
|
+
function toOllamaMessages(system, messages) {
|
|
9
|
+
const out = [{ role: "system", content: system }];
|
|
10
|
+
for (const msg of messages) {
|
|
11
|
+
if (typeof msg.content === "string") {
|
|
12
|
+
out.push({ role: msg.role, content: msg.content });
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
for (const block of msg.content) {
|
|
16
|
+
if (block.type === "text") {
|
|
17
|
+
out.push({ role: msg.role, content: block.text });
|
|
18
|
+
}
|
|
19
|
+
else if (block.type === "tool_result") {
|
|
20
|
+
out.push({ role: "tool", tool_call_id: block.tool_use_id, content: block.content });
|
|
21
|
+
}
|
|
22
|
+
else if (block.type === "tool_use") {
|
|
23
|
+
out.push({
|
|
24
|
+
role: "assistant",
|
|
25
|
+
tool_calls: [{ id: block.id, type: "function", function: { name: block.name, arguments: JSON.stringify(block.input) } }],
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
export class OllamaProvider {
|
|
34
|
+
name = "ollama";
|
|
35
|
+
contextWindow = 32_000;
|
|
36
|
+
baseUrl;
|
|
37
|
+
model;
|
|
38
|
+
constructor(model, baseUrl) {
|
|
39
|
+
this.baseUrl = baseUrl ?? "http://localhost:11434";
|
|
40
|
+
this.model = model ?? "qwen2.5-coder:14b";
|
|
41
|
+
}
|
|
42
|
+
async chat(system, messages, tools) {
|
|
43
|
+
const body = {
|
|
44
|
+
model: this.model,
|
|
45
|
+
messages: toOllamaMessages(system, messages),
|
|
46
|
+
stream: false,
|
|
47
|
+
};
|
|
48
|
+
if (tools.length > 0)
|
|
49
|
+
body.tools = toOllamaTools(tools);
|
|
50
|
+
const res = await fetch(`${this.baseUrl}/api/chat`, {
|
|
51
|
+
method: "POST",
|
|
52
|
+
headers: { "Content-Type": "application/json" },
|
|
53
|
+
body: JSON.stringify(body),
|
|
54
|
+
});
|
|
55
|
+
if (!res.ok) {
|
|
56
|
+
const text = await res.text();
|
|
57
|
+
throw new Error(`Ollama API error ${res.status}: ${text}`);
|
|
58
|
+
}
|
|
59
|
+
const data = await res.json();
|
|
60
|
+
const message = data.message;
|
|
61
|
+
const content = [];
|
|
62
|
+
if (message?.content && typeof message.content === "string") {
|
|
63
|
+
content.push({ type: "text", text: message.content });
|
|
64
|
+
}
|
|
65
|
+
const toolCalls = message?.tool_calls;
|
|
66
|
+
if (toolCalls) {
|
|
67
|
+
for (const tc of toolCalls) {
|
|
68
|
+
const fn = tc.function;
|
|
69
|
+
content.push({
|
|
70
|
+
type: "tool_use",
|
|
71
|
+
id: `ollama-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
72
|
+
name: fn.name,
|
|
73
|
+
input: typeof fn.arguments === "string" ? JSON.parse(fn.arguments) : fn.arguments,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const stop_reason = toolCalls && toolCalls.length > 0 ? "tool_use" : "end_turn";
|
|
78
|
+
return { content, stop_reason };
|
|
79
|
+
}
|
|
80
|
+
async *chatStream(system, messages, tools) {
|
|
81
|
+
const body = {
|
|
82
|
+
model: this.model,
|
|
83
|
+
messages: toOllamaMessages(system, messages),
|
|
84
|
+
stream: true,
|
|
85
|
+
};
|
|
86
|
+
if (tools.length > 0)
|
|
87
|
+
body.tools = toOllamaTools(tools);
|
|
88
|
+
const res = await fetch(`${this.baseUrl}/api/chat`, {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: { "Content-Type": "application/json" },
|
|
91
|
+
body: JSON.stringify(body),
|
|
92
|
+
});
|
|
93
|
+
if (!res.ok) {
|
|
94
|
+
const text = await res.text();
|
|
95
|
+
throw new Error(`Ollama API error ${res.status}: ${text}`);
|
|
96
|
+
}
|
|
97
|
+
if (!res.body)
|
|
98
|
+
throw new Error("Provider returned empty response body");
|
|
99
|
+
const reader = res.body.getReader();
|
|
100
|
+
const decoder = new TextDecoder();
|
|
101
|
+
let buf = "";
|
|
102
|
+
let stopReason = "end_turn";
|
|
103
|
+
for (;;) {
|
|
104
|
+
const { done, value } = await reader.read();
|
|
105
|
+
if (done)
|
|
106
|
+
break;
|
|
107
|
+
buf += decoder.decode(value, { stream: true });
|
|
108
|
+
const lines = buf.split("\n");
|
|
109
|
+
buf = lines.pop();
|
|
110
|
+
for (const line of lines) {
|
|
111
|
+
if (!line.trim())
|
|
112
|
+
continue;
|
|
113
|
+
let chunk;
|
|
114
|
+
try {
|
|
115
|
+
chunk = JSON.parse(line);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const message = chunk.message;
|
|
121
|
+
if (message?.content && typeof message.content === "string") {
|
|
122
|
+
yield { type: "text_delta", text: message.content };
|
|
123
|
+
}
|
|
124
|
+
// Ollama sends tool_calls in the final message (done=true)
|
|
125
|
+
const tcalls = message?.tool_calls;
|
|
126
|
+
if (tcalls) {
|
|
127
|
+
stopReason = "tool_use";
|
|
128
|
+
for (const tc of tcalls) {
|
|
129
|
+
const fn = tc.function;
|
|
130
|
+
const id = `ollama-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
131
|
+
yield { type: "tool_use_start", id, name: fn.name };
|
|
132
|
+
yield { type: "tool_use_delta", id, json: JSON.stringify(typeof fn.arguments === "string" ? JSON.parse(fn.arguments) : fn.arguments) };
|
|
133
|
+
yield { type: "tool_use_end", id };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (chunk.done === true)
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
yield { type: "done", stop_reason: stopReason };
|
|
141
|
+
}
|
|
142
|
+
}
|