@phren/agent 0.0.1 → 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.
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Provider management: auth, model registry, live switching.
3
+ *
4
+ * /provider — show configured providers + auth status
5
+ * /provider add — interactive provider setup (enter key, auth login, etc.)
6
+ * /provider switch — change active provider mid-session
7
+ * /model add <id> — add a custom model to the catalog
8
+ */
9
+ import * as fs from "fs";
10
+ import * as path from "path";
11
+ import * as os from "os";
12
+ import { hasCodexToken } from "../providers/codex-auth.js";
13
+ const CONFIG_DIR = path.join(os.homedir(), ".phren-agent");
14
+ const PROVIDERS_FILE = path.join(CONFIG_DIR, "providers.json");
15
+ export function getProviderStatuses() {
16
+ return [
17
+ {
18
+ name: "openrouter",
19
+ configured: !!process.env.OPENROUTER_API_KEY,
20
+ authMethod: "api-key",
21
+ keyEnvVar: "OPENROUTER_API_KEY",
22
+ models: ["anthropic/claude-sonnet-4", "anthropic/claude-opus-4", "openai/gpt-4o", "openai/o4-mini", "google/gemini-2.5-pro", "deepseek/deepseek-r1"],
23
+ },
24
+ {
25
+ name: "anthropic",
26
+ configured: !!process.env.ANTHROPIC_API_KEY,
27
+ authMethod: "api-key",
28
+ keyEnvVar: "ANTHROPIC_API_KEY",
29
+ models: ["claude-sonnet-4-20250514", "claude-opus-4-20250514", "claude-haiku-4-5-20251001"],
30
+ },
31
+ {
32
+ name: "openai",
33
+ configured: !!process.env.OPENAI_API_KEY,
34
+ authMethod: "api-key",
35
+ keyEnvVar: "OPENAI_API_KEY",
36
+ models: ["gpt-4o", "o4-mini", "o3"],
37
+ },
38
+ {
39
+ name: "codex",
40
+ configured: hasCodexToken(),
41
+ authMethod: "oauth",
42
+ models: ["gpt-4o", "o4-mini", "o3"],
43
+ },
44
+ {
45
+ name: "ollama",
46
+ configured: (process.env.PHREN_OLLAMA_URL ?? "").toLowerCase() !== "off",
47
+ authMethod: "local",
48
+ models: ["qwen2.5-coder:14b", "llama3.2", "deepseek-r1:14b"],
49
+ },
50
+ ];
51
+ }
52
+ function loadConfig() {
53
+ try {
54
+ return JSON.parse(fs.readFileSync(PROVIDERS_FILE, "utf-8"));
55
+ }
56
+ catch {
57
+ return { customModels: [] };
58
+ }
59
+ }
60
+ function saveConfig(config) {
61
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
62
+ const tmp = `${PROVIDERS_FILE}.${process.pid}.tmp`;
63
+ fs.writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n");
64
+ fs.renameSync(tmp, PROVIDERS_FILE);
65
+ }
66
+ export function addCustomModel(id, provider, opts) {
67
+ const config = loadConfig();
68
+ // Remove existing with same id
69
+ config.customModels = config.customModels.filter((m) => m.id !== id);
70
+ const entry = {
71
+ id,
72
+ provider,
73
+ label: opts?.label ?? id,
74
+ contextWindow: opts?.contextWindow ?? 128_000,
75
+ reasoning: opts?.reasoning ?? null,
76
+ reasoningRange: opts?.reasoningRange ?? [],
77
+ addedAt: new Date().toISOString(),
78
+ };
79
+ config.customModels.push(entry);
80
+ saveConfig(config);
81
+ return entry;
82
+ }
83
+ export function removeCustomModel(id) {
84
+ const config = loadConfig();
85
+ const before = config.customModels.length;
86
+ config.customModels = config.customModels.filter((m) => m.id !== id);
87
+ if (config.customModels.length === before)
88
+ return false;
89
+ saveConfig(config);
90
+ return true;
91
+ }
92
+ export function getCustomModels() {
93
+ return loadConfig().customModels;
94
+ }
95
+ /** Get all models for a provider (built-in + custom). */
96
+ export async function getAllModelsForProvider(provider, currentModel) {
97
+ // Import dynamically to avoid circular dep
98
+ const { getAvailableModels } = await import("./model-picker.js");
99
+ const builtIn = getAvailableModels(provider, currentModel);
100
+ // Add custom models for this provider
101
+ const custom = getCustomModels().filter((m) => m.provider === provider);
102
+ for (const c of custom) {
103
+ if (!builtIn.some((b) => b.id === c.id)) {
104
+ builtIn.push({
105
+ id: c.id,
106
+ provider: provider,
107
+ label: c.label + " ★",
108
+ reasoning: c.reasoning,
109
+ reasoningRange: c.reasoningRange,
110
+ contextWindow: c.contextWindow,
111
+ });
112
+ }
113
+ }
114
+ return builtIn;
115
+ }
116
+ // ── Format helpers for CLI display ──────────────────────────────────────────
117
+ const DIM = "\x1b[2m";
118
+ const BOLD = "\x1b[1m";
119
+ const GREEN = "\x1b[32m";
120
+ const RED = "\x1b[31m";
121
+ const CYAN = "\x1b[36m";
122
+ const YELLOW = "\x1b[33m";
123
+ const RESET = "\x1b[0m";
124
+ export function formatProviderList() {
125
+ const statuses = getProviderStatuses();
126
+ const lines = [`\n ${BOLD}Providers${RESET}\n`];
127
+ for (const p of statuses) {
128
+ const icon = p.configured ? `${GREEN}●${RESET}` : `${RED}○${RESET}`;
129
+ const auth = p.configured ? `${GREEN}configured${RESET}` : p.authMethod === "oauth"
130
+ ? `${DIM}run: phren agent auth login${RESET}`
131
+ : p.keyEnvVar
132
+ ? `${DIM}set ${p.keyEnvVar}${RESET}`
133
+ : `${DIM}local${RESET}`;
134
+ const modelCount = `${DIM}${p.models.length} models${RESET}`;
135
+ lines.push(` ${icon} ${BOLD}${p.name}${RESET} ${auth} ${modelCount}`);
136
+ }
137
+ const custom = getCustomModels();
138
+ if (custom.length > 0) {
139
+ lines.push(`\n ${DIM}Custom models: ${custom.map((m) => m.id).join(", ")}${RESET}`);
140
+ }
141
+ lines.push(`\n ${DIM}/provider add${RESET} to configure ${DIM}/model add <id>${RESET} to add model\n`);
142
+ return lines.join("\n");
143
+ }
144
+ export function formatModelAddHelp() {
145
+ return `${DIM}Usage: /model add <model-id> [provider=X] [context=128000] [reasoning=low|medium|high|max]
146
+
147
+ Examples:
148
+ /model add meta-llama/llama-3.1-405b provider=openrouter context=128000
149
+ /model add claude-3-haiku-20240307 provider=anthropic
150
+ /model add codestral:latest provider=ollama reasoning=medium${RESET}`;
151
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Regex-based syntax highlighter for terminal code blocks.
3
+ * Zero external dependencies — uses raw ANSI escape codes.
4
+ * Line-by-line, single-pass processing.
5
+ */
6
+ const ESC = "\x1b[";
7
+ const RESET = `${ESC}0m`;
8
+ const BOLD = `${ESC}1m`;
9
+ const DIM = `${ESC}2m`;
10
+ const MAGENTA = `${ESC}35m`;
11
+ const GREEN = `${ESC}32m`;
12
+ const YELLOW = `${ESC}33m`;
13
+ const CYAN = `${ESC}36m`;
14
+ const GRAY = `${ESC}90m`;
15
+ // ── Language detection ──────────────────────────────────────────────
16
+ const EXT_MAP = {
17
+ ts: "typescript", tsx: "typescript", mts: "typescript", cts: "typescript",
18
+ js: "javascript", jsx: "javascript", mjs: "javascript", cjs: "javascript",
19
+ py: "python", pyw: "python",
20
+ sh: "bash", bash: "bash", zsh: "bash", fish: "bash",
21
+ json: "json", jsonc: "json", json5: "json",
22
+ css: "css", scss: "scss", sass: "scss", less: "css",
23
+ // pass-through aliases
24
+ typescript: "typescript", javascript: "javascript",
25
+ python: "python", shell: "bash",
26
+ };
27
+ export function detectLanguage(filename) {
28
+ const lower = filename.toLowerCase().replace(/^\./, "");
29
+ return EXT_MAP[lower] ?? "generic";
30
+ }
31
+ // Helper: replace matches while preserving non-matched segments
32
+ function colorize(line, rules) {
33
+ // We process rules sequentially; each rule operates on uncolored segments only.
34
+ // Segments already colored are wrapped in \x1b and end with RESET.
35
+ let result = line;
36
+ for (const [re, color] of rules) {
37
+ result = result.replace(re, (match) => `${color}${match}${RESET}`);
38
+ }
39
+ return result;
40
+ }
41
+ // ── TypeScript / JavaScript ─────────────────────────────────────────
42
+ const TS_KEYWORDS = /\b(const|let|var|function|return|if|else|for|while|do|switch|case|break|continue|new|typeof|instanceof|void|delete|throw|try|catch|finally|import|export|from|default|class|extends|implements|async|await|yield|type|interface|enum|namespace|declare|readonly|abstract|static|get|set|of|in|as|is)\b/g;
43
+ const tsHighlight = (line) => {
44
+ // Single-line comment
45
+ const commentIdx = line.indexOf("//");
46
+ if (commentIdx !== -1 && !isInsideString(line, commentIdx)) {
47
+ const code = line.slice(0, commentIdx);
48
+ const comment = line.slice(commentIdx);
49
+ return highlightTSCode(code) + `${GRAY}${comment}${RESET}`;
50
+ }
51
+ // Block comment (whole line)
52
+ if (line.trimStart().startsWith("/*") || line.trimStart().startsWith("*")) {
53
+ return `${GRAY}${line}${RESET}`;
54
+ }
55
+ return highlightTSCode(line);
56
+ };
57
+ function highlightTSCode(line) {
58
+ return colorize(line, [
59
+ [/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`/g, GREEN], // strings
60
+ [/\b\d+(\.\d+)?\b/g, YELLOW], // numbers
61
+ [TS_KEYWORDS, MAGENTA], // keywords
62
+ [/(?<=[:]\s*)\b[A-Z]\w*/g, CYAN], // types after :
63
+ [/(?<=\bas\s+)\b[A-Z]\w*/g, CYAN], // types after as
64
+ ]);
65
+ }
66
+ // ── Python ──────────────────────────────────────────────────────────
67
+ const PY_KEYWORDS = /\b(def|class|if|elif|else|for|while|return|import|from|with|as|try|except|finally|raise|pass|break|continue|yield|lambda|and|or|not|in|is|None|True|False|global|nonlocal|assert|del|async|await)\b/g;
68
+ const pyHighlight = (line) => {
69
+ // Comment
70
+ const hashIdx = line.indexOf("#");
71
+ if (hashIdx !== -1 && !isInsideString(line, hashIdx)) {
72
+ const code = line.slice(0, hashIdx);
73
+ const comment = line.slice(hashIdx);
74
+ return highlightPYCode(code) + `${GRAY}${comment}${RESET}`;
75
+ }
76
+ // Decorator
77
+ if (line.trimStart().startsWith("@")) {
78
+ return `${YELLOW}${line}${RESET}`;
79
+ }
80
+ return highlightPYCode(line);
81
+ };
82
+ function highlightPYCode(line) {
83
+ return colorize(line, [
84
+ [/""".*?"""|'''.*?'''|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, GREEN], // strings
85
+ [/\b\d+(\.\d+)?\b/g, YELLOW], // numbers
86
+ [PY_KEYWORDS, MAGENTA], // keywords
87
+ ]);
88
+ }
89
+ // ── Bash / Shell ────────────────────────────────────────────────────
90
+ const BASH_KEYWORDS = /\b(if|then|else|elif|fi|for|do|done|while|until|case|esac|in|function|select|time|coproc|local|export|declare|unset|readonly|return|exit|source|eval)\b/g;
91
+ const bashHighlight = (line) => {
92
+ // Comment
93
+ const hashIdx = line.indexOf("#");
94
+ if (hashIdx === 0 || (hashIdx > 0 && line[hashIdx - 1] === " " && !isInsideString(line, hashIdx))) {
95
+ const code = line.slice(0, hashIdx);
96
+ const comment = line.slice(hashIdx);
97
+ return highlightBashCode(code) + `${GRAY}${comment}${RESET}`;
98
+ }
99
+ return highlightBashCode(line);
100
+ };
101
+ function highlightBashCode(line) {
102
+ return colorize(line, [
103
+ [/"(?:[^"\\]|\\.)*"|'[^']*'/g, GREEN], // strings
104
+ [/\$\{?\w+\}?/g, CYAN], // variables
105
+ [/\b\d+\b/g, YELLOW], // numbers
106
+ [BASH_KEYWORDS, MAGENTA], // keywords
107
+ [/(?<=\|\s*)\w+/g, BOLD], // commands after pipe
108
+ ]);
109
+ }
110
+ // ── JSON ────────────────────────────────────────────────────────────
111
+ const jsonHighlight = (line) => {
112
+ return colorize(line, [
113
+ [/"[^"]*"\s*(?=:)/g, CYAN], // keys
114
+ [/:\s*"[^"]*"/g, GREEN], // string values
115
+ [/\b\d+(\.\d+)?([eE][+-]?\d+)?\b/g, YELLOW], // numbers
116
+ [/\b(true|false|null)\b/g, MAGENTA], // literals
117
+ ]);
118
+ };
119
+ // ── CSS / SCSS ──────────────────────────────────────────────────────
120
+ const cssHighlight = (line) => {
121
+ const trimmed = line.trimStart();
122
+ // Comment
123
+ if (trimmed.startsWith("/*") || trimmed.startsWith("*") || trimmed.startsWith("//")) {
124
+ return `${GRAY}${line}${RESET}`;
125
+ }
126
+ // Selector line (no colon, or starts with . # & @ or tag)
127
+ if (/^[.#&@a-zA-Z]/.test(trimmed) && !trimmed.includes(":")) {
128
+ return `${CYAN}${line}${RESET}`;
129
+ }
130
+ // Property: value
131
+ const propMatch = line.match(/^(\s*)([\w-]+)(\s*:\s*)(.+)/);
132
+ if (propMatch) {
133
+ return `${propMatch[1]}${MAGENTA}${propMatch[2]}${RESET}${propMatch[3]}${GREEN}${propMatch[4]}${RESET}`;
134
+ }
135
+ return line;
136
+ };
137
+ // ── Generic fallback ────────────────────────────────────────────────
138
+ const genericHighlight = (line) => {
139
+ return colorize(line, [
140
+ [/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, GREEN], // strings
141
+ [/\/\/.*$|#.*$/g, GRAY], // comments
142
+ [/\b\d+(\.\d+)?\b/g, YELLOW], // numbers
143
+ ]);
144
+ };
145
+ // ── Dispatcher ──────────────────────────────────────────────────────
146
+ const HIGHLIGHTERS = {
147
+ typescript: tsHighlight,
148
+ javascript: tsHighlight,
149
+ python: pyHighlight,
150
+ bash: bashHighlight,
151
+ json: jsonHighlight,
152
+ css: cssHighlight,
153
+ scss: cssHighlight,
154
+ generic: genericHighlight,
155
+ };
156
+ /**
157
+ * Highlight a code string for terminal output.
158
+ * Returns the input with ANSI color codes applied.
159
+ */
160
+ export function highlightCode(code, language) {
161
+ const lang = EXT_MAP[language.toLowerCase()] ?? language.toLowerCase();
162
+ const hl = HIGHLIGHTERS[lang] ?? genericHighlight;
163
+ return code
164
+ .split("\n")
165
+ .map((line) => hl(line))
166
+ .join("\n");
167
+ }
168
+ // ── Utilities ───────────────────────────────────────────────────────
169
+ /** Rough check: is position idx inside a string literal? */
170
+ function isInsideString(line, idx) {
171
+ let inSingle = false;
172
+ let inDouble = false;
173
+ let inTemplate = false;
174
+ for (let i = 0; i < idx; i++) {
175
+ const ch = line[i];
176
+ if (ch === "\\" && (inSingle || inDouble || inTemplate)) {
177
+ i++; // skip escaped char
178
+ continue;
179
+ }
180
+ if (ch === "'" && !inDouble && !inTemplate)
181
+ inSingle = !inSingle;
182
+ else if (ch === '"' && !inSingle && !inTemplate)
183
+ inDouble = !inDouble;
184
+ else if (ch === "`" && !inSingle && !inDouble)
185
+ inTemplate = !inTemplate;
186
+ }
187
+ return inSingle || inDouble || inTemplate;
188
+ }
@@ -83,15 +83,6 @@ function statusColor(status) {
83
83
  case "cancelled": return s.gray;
84
84
  }
85
85
  }
86
- function statusBg(status, selected) {
87
- if (selected)
88
- return s.invert;
89
- switch (status) {
90
- case "running": return s.bgGreen;
91
- case "error": return s.bgRed;
92
- default: return s.bgGray;
93
- }
94
- }
95
86
  // ── Tool call formatting ─────────────────────────────────────────────────────
96
87
  function formatToolStart(toolName, input) {
97
88
  const preview = JSON.stringify(input).slice(0, 60);
@@ -44,7 +44,10 @@ export function isAllowed(toolName, input) {
44
44
  export function addAllow(toolName, input, scope) {
45
45
  if (scope === "once")
46
46
  return; // "once" approvals don't persist
47
- const pattern = scope === "tool" ? "*" : extractPattern(toolName, input);
47
+ // For shell commands, never allow "*" always scope to the binary name
48
+ const pattern = scope === "tool" && toolName !== "shell"
49
+ ? "*"
50
+ : extractPattern(toolName, input);
48
51
  // Avoid duplicates
49
52
  const exists = sessionAllowlist.some((e) => e.toolName === toolName && e.pattern === pattern);
50
53
  if (!exists) {
@@ -9,6 +9,9 @@
9
9
  */
10
10
  import * as readline from "node:readline";
11
11
  import { addAllow } from "./allowlist.js";
12
+ // ── Prompt serialization lock ───────────────────────────────────────────
13
+ // Prevents concurrent askUser() calls from interleaving their prompts.
14
+ let promptQueue = Promise.resolve();
12
15
  // ── ANSI colors ─────────────────────────────────────────────────────────
13
16
  const RESET = "\x1b[0m";
14
17
  const BOLD = "\x1b[1m";
@@ -94,31 +97,42 @@ function summarizeCall(toolName, input) {
94
97
  * Side effect: "a" and "s" responses add to the session allowlist.
95
98
  */
96
99
  export async function askUser(toolName, input, reason) {
97
- const risk = classifyRisk(toolName);
98
- const color = riskColor(risk);
99
- const label = riskLabel(risk);
100
- const summary = summarizeCall(toolName, input);
101
- // Header
102
- process.stderr.write(`\n${color}${BOLD}[${label}]${RESET} ${BOLD}${toolName}${RESET}\n`);
103
- process.stderr.write(`${DIM} ${reason}${RESET}\n`);
104
- process.stderr.write(`${CYAN} ${summary}${RESET}\n`);
105
- // Show full input for shell commands or when details matter
106
- if (toolName === "shell") {
107
- const cmd = input.command || "";
108
- if (cmd.length > 120) {
109
- process.stderr.write(`${DIM} Full command:${RESET}\n`);
110
- process.stderr.write(`${DIM} ${cmd}${RESET}\n`);
100
+ // Serialize: wait for any prior prompt to finish before showing ours
101
+ let resolve;
102
+ const gate = new Promise((r) => { resolve = r; });
103
+ const previous = promptQueue;
104
+ promptQueue = gate;
105
+ await previous;
106
+ try {
107
+ const risk = classifyRisk(toolName);
108
+ const color = riskColor(risk);
109
+ const label = riskLabel(risk);
110
+ const summary = summarizeCall(toolName, input);
111
+ // Header
112
+ process.stderr.write(`\n${color}${BOLD}[${label}]${RESET} ${BOLD}${toolName}${RESET}\n`);
113
+ process.stderr.write(`${DIM} ${reason}${RESET}\n`);
114
+ process.stderr.write(`${CYAN} ${summary}${RESET}\n`);
115
+ // Show full input for shell commands or when details matter
116
+ if (toolName === "shell") {
117
+ const cmd = input.command || "";
118
+ if (cmd.length > 120) {
119
+ process.stderr.write(`${DIM} Full command:${RESET}\n`);
120
+ process.stderr.write(`${DIM} ${cmd}${RESET}\n`);
121
+ }
111
122
  }
123
+ const result = await promptKey();
124
+ // Persist allowlist entries for session/tool scopes
125
+ if (result === "allow-session") {
126
+ addAllow(toolName, input, "session");
127
+ }
128
+ else if (result === "allow-tool") {
129
+ addAllow(toolName, input, "tool");
130
+ }
131
+ return result !== "deny";
112
132
  }
113
- const result = await promptKey();
114
- // Persist allowlist entries for session/tool scopes
115
- if (result === "allow-session") {
116
- addAllow(toolName, input, "session");
117
- }
118
- else if (result === "allow-tool") {
119
- addAllow(toolName, input, "tool");
133
+ finally {
134
+ resolve();
120
135
  }
121
- return result !== "deny";
122
136
  }
123
137
  /**
124
138
  * Read a single keypress from stdin.
@@ -34,6 +34,8 @@ const KEY_PATTERNS = [
34
34
  "DATABASE_URL",
35
35
  "KUBECONFIG",
36
36
  "DOCKER_AUTH_CONFIG",
37
+ "PGPASSWORD",
38
+ "MYSQL_PWD",
37
39
  ];
38
40
  /** Suffix patterns that also match connection strings and auth configs. */
39
41
  const SECRET_SUFFIX_PATTERNS = ["_URI", "_DSN"];
@@ -1,11 +1,13 @@
1
1
  export class AnthropicProvider {
2
2
  name = "anthropic";
3
3
  contextWindow = 200_000;
4
+ maxOutputTokens;
4
5
  apiKey;
5
6
  model;
6
- constructor(apiKey, model) {
7
+ constructor(apiKey, model, maxOutputTokens) {
7
8
  this.apiKey = apiKey;
8
9
  this.model = model ?? "claude-sonnet-4-20250514";
10
+ this.maxOutputTokens = maxOutputTokens ?? 8192;
9
11
  }
10
12
  async chat(system, messages, tools) {
11
13
  const body = {
@@ -15,7 +17,7 @@ export class AnthropicProvider {
15
17
  role: m.role,
16
18
  content: m.content,
17
19
  })),
18
- max_tokens: 8192,
20
+ max_tokens: this.maxOutputTokens,
19
21
  };
20
22
  if (tools.length > 0) {
21
23
  body.tools = tools.map((t) => ({
@@ -54,7 +56,7 @@ export class AnthropicProvider {
54
56
  model: this.model,
55
57
  system,
56
58
  messages: messages.map((m) => ({ role: m.role, content: m.content })),
57
- max_tokens: 8192,
59
+ max_tokens: this.maxOutputTokens,
58
60
  stream: true,
59
61
  };
60
62
  if (tools.length > 0) {
@@ -15,7 +15,7 @@ const CALLBACK_PORT = 1455;
15
15
  const SCOPES = "openid profile email offline_access";
16
16
  function tokenPath() {
17
17
  const dir = path.join(os.homedir(), ".phren-agent");
18
- fs.mkdirSync(dir, { recursive: true });
18
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
19
19
  return path.join(dir, "codex-token.json");
20
20
  }
21
21
  function generatePKCE() {
@@ -114,9 +114,11 @@ function parseResponsesOutput(data) {
114
114
  export class CodexProvider {
115
115
  name = "codex";
116
116
  contextWindow = 128_000;
117
+ maxOutputTokens;
117
118
  model;
118
- constructor(model) {
119
- this.model = model ?? "gpt-5.2-codex";
119
+ constructor(model, maxOutputTokens) {
120
+ this.model = model ?? "gpt-5.3-codex";
121
+ this.maxOutputTokens = maxOutputTokens ?? 8192;
120
122
  }
121
123
  async chat(system, messages, tools) {
122
124
  const { accessToken } = await getAccessToken();
@@ -33,16 +33,19 @@ function toOllamaMessages(system, messages) {
33
33
  export class OllamaProvider {
34
34
  name = "ollama";
35
35
  contextWindow = 32_000;
36
+ maxOutputTokens;
36
37
  baseUrl;
37
38
  model;
38
- constructor(model, baseUrl) {
39
+ constructor(model, baseUrl, maxOutputTokens) {
39
40
  this.baseUrl = baseUrl ?? "http://localhost:11434";
40
41
  this.model = model ?? "qwen2.5-coder:14b";
42
+ this.maxOutputTokens = maxOutputTokens ?? 8192;
41
43
  }
42
44
  async chat(system, messages, tools) {
43
45
  const body = {
44
46
  model: this.model,
45
47
  messages: toOllamaMessages(system, messages),
48
+ options: { num_predict: this.maxOutputTokens },
46
49
  stream: false,
47
50
  };
48
51
  if (tools.length > 0)
@@ -81,6 +84,7 @@ export class OllamaProvider {
81
84
  const body = {
82
85
  model: this.model,
83
86
  messages: toOllamaMessages(system, messages),
87
+ options: { num_predict: this.maxOutputTokens },
84
88
  stream: true,
85
89
  };
86
90
  if (tools.length > 0)
@@ -2,19 +2,21 @@ import { toOpenAiTools, toOpenAiMessages, parseOpenAiResponse, parseOpenAiStream
2
2
  export class OpenRouterProvider {
3
3
  name = "openrouter";
4
4
  contextWindow = 200_000;
5
+ maxOutputTokens;
5
6
  apiKey;
6
7
  model;
7
8
  baseUrl;
8
- constructor(apiKey, model, baseUrl) {
9
+ constructor(apiKey, model, baseUrl, maxOutputTokens) {
9
10
  this.apiKey = apiKey;
10
11
  this.model = model ?? "anthropic/claude-sonnet-4-20250514";
11
12
  this.baseUrl = baseUrl ?? "https://openrouter.ai/api/v1";
13
+ this.maxOutputTokens = maxOutputTokens ?? 8192;
12
14
  }
13
15
  async chat(system, messages, tools) {
14
16
  const body = {
15
17
  model: this.model,
16
18
  messages: toOpenAiMessages(system, messages),
17
- max_tokens: 8192,
19
+ max_tokens: this.maxOutputTokens,
18
20
  };
19
21
  if (tools.length > 0)
20
22
  body.tools = toOpenAiTools(tools);
@@ -38,7 +40,7 @@ export class OpenRouterProvider {
38
40
  const body = {
39
41
  model: this.model,
40
42
  messages: toOpenAiMessages(system, messages),
41
- max_tokens: 8192,
43
+ max_tokens: this.maxOutputTokens,
42
44
  stream: true,
43
45
  stream_options: { include_usage: true },
44
46
  };
@@ -65,19 +67,21 @@ export class OpenRouterProvider {
65
67
  export class OpenAiProvider {
66
68
  name = "openai";
67
69
  contextWindow = 128_000;
70
+ maxOutputTokens;
68
71
  apiKey;
69
72
  model;
70
73
  baseUrl;
71
- constructor(apiKey, model, baseUrl) {
74
+ constructor(apiKey, model, baseUrl, maxOutputTokens) {
72
75
  this.apiKey = apiKey;
73
76
  this.model = model ?? "gpt-4o";
74
77
  this.baseUrl = baseUrl ?? "https://api.openai.com/v1";
78
+ this.maxOutputTokens = maxOutputTokens ?? 8192;
75
79
  }
76
80
  async chat(system, messages, tools) {
77
81
  const body = {
78
82
  model: this.model,
79
83
  messages: toOpenAiMessages(system, messages),
80
- max_tokens: 8192,
84
+ max_tokens: this.maxOutputTokens,
81
85
  };
82
86
  if (tools.length > 0)
83
87
  body.tools = toOpenAiTools(tools);
@@ -96,7 +100,7 @@ export class OpenAiProvider {
96
100
  const body = {
97
101
  model: this.model,
98
102
  messages: toOpenAiMessages(system, messages),
99
- max_tokens: 8192,
103
+ max_tokens: this.maxOutputTokens,
100
104
  stream: true,
101
105
  stream_options: { include_usage: true },
102
106
  };
@@ -3,36 +3,45 @@ import { AnthropicProvider } from "./anthropic.js";
3
3
  import { OllamaProvider } from "./ollama.js";
4
4
  import { CodexProvider } from "./codex.js";
5
5
  import { hasCodexToken } from "./codex-auth.js";
6
- export function resolveProvider(overrideProvider, overrideModel) {
6
+ import { lookupMaxOutputTokens } from "../cost.js";
7
+ export function resolveProvider(overrideProvider, overrideModel, overrideMaxOutput) {
7
8
  const explicit = overrideProvider ?? process.env.PHREN_AGENT_PROVIDER;
9
+ // Resolve max output tokens: CLI override > model lookup > default 8192
10
+ const resolveLimit = (model) => overrideMaxOutput ?? lookupMaxOutputTokens(model);
8
11
  if (explicit === "openrouter" || (!explicit && process.env.OPENROUTER_API_KEY)) {
9
12
  const key = process.env.OPENROUTER_API_KEY;
10
13
  if (!key)
11
14
  throw new Error("OPENROUTER_API_KEY is required for OpenRouter provider.");
12
- return new OpenRouterProvider(key, overrideModel);
15
+ const model = overrideModel ?? "anthropic/claude-sonnet-4-20250514";
16
+ return new OpenRouterProvider(key, overrideModel, undefined, resolveLimit(model));
13
17
  }
14
18
  if (explicit === "anthropic" || (!explicit && process.env.ANTHROPIC_API_KEY)) {
15
19
  const key = process.env.ANTHROPIC_API_KEY;
16
20
  if (!key)
17
21
  throw new Error("ANTHROPIC_API_KEY is required for Anthropic provider.");
18
- return new AnthropicProvider(key, overrideModel);
22
+ const model = overrideModel ?? "claude-sonnet-4-20250514";
23
+ return new AnthropicProvider(key, overrideModel, resolveLimit(model));
19
24
  }
20
25
  if (explicit === "openai" || (!explicit && process.env.OPENAI_API_KEY)) {
21
26
  const key = process.env.OPENAI_API_KEY;
22
27
  if (!key)
23
28
  throw new Error("OPENAI_API_KEY is required for OpenAI provider.");
24
- return new OpenAiProvider(key, overrideModel);
29
+ const model = overrideModel ?? "gpt-4o";
30
+ return new OpenAiProvider(key, overrideModel, undefined, resolveLimit(model));
25
31
  }
26
32
  // Codex: uses your ChatGPT subscription directly — no API key, no middleman
27
33
  if (explicit === "codex" || (!explicit && hasCodexToken())) {
28
- return new CodexProvider(overrideModel);
34
+ const model = overrideModel ?? "gpt-5.2-codex";
35
+ return new CodexProvider(overrideModel, resolveLimit(model));
29
36
  }
30
37
  if (explicit === "ollama" || (!explicit && process.env.PHREN_OLLAMA_URL && process.env.PHREN_OLLAMA_URL !== "off")) {
31
- return new OllamaProvider(overrideModel, process.env.PHREN_OLLAMA_URL);
38
+ const model = overrideModel ?? "qwen2.5-coder:14b";
39
+ return new OllamaProvider(overrideModel, process.env.PHREN_OLLAMA_URL, resolveLimit(model));
32
40
  }
33
41
  // Last resort: try Ollama at default URL
34
42
  if (!explicit) {
35
- return new OllamaProvider(overrideModel);
43
+ const model = overrideModel ?? "qwen2.5-coder:14b";
44
+ return new OllamaProvider(overrideModel, undefined, resolveLimit(model));
36
45
  }
37
46
  throw new Error(`Unknown provider "${explicit}". Supported: openrouter, anthropic, openai, codex, ollama.\n` +
38
47
  "Set one of: OPENROUTER_API_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY, or run 'phren-agent auth login' for Codex.");