freddie 0.0.122 → 0.0.123

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "freddie",
3
- "version": "0.0.122",
3
+ "version": "0.0.123",
4
4
  "type": "module",
5
5
  "description": "Open JS agent harness built on pi-mono, floosie, xstate, and anentrypoint-design",
6
6
  "bin": {
@@ -27,7 +27,7 @@
27
27
  "@mariozechner/pi-ai": "^0.70.6",
28
28
  "@mariozechner/pi-coding-agent": "^0.70.6",
29
29
  "@mariozechner/pi-tui": "^0.70.6",
30
- "acptoapi": "^1.0.115",
30
+ "acptoapi": "^1.0.140",
31
31
  "anentrypoint-design": "latest",
32
32
  "commander": "^14.0.0",
33
33
  "express": "^5.0.0",
@@ -3,47 +3,46 @@ import { parseTextToolCalls } from './tool_call_text.js'
3
3
 
4
4
  const log = logger('acptoapi')
5
5
 
6
- // acptoapi may take minutes to answer when it serially walks dead providers
7
- // (each ~90s timeout) before a live one responds. Node's default undici
8
- // headersTimeout/bodyTimeout (~300s) would throw UND_ERR_HEADERS_TIMEOUT
9
- // mid-walk, so we raise them to 0 (disabled) on the global dispatcher and let
10
- // our AbortController be the single source of truth for the overall deadline
11
- // (default 240s, override via FREDDIE_LLM_TIMEOUT_MS). Use setGlobalDispatcher
12
- // once rather than a per-request Agent so we don't leak a dispatcher per call.
13
6
  // Browser-safe env read: this module evaluates in a plain browser context where
14
7
  // `process` is undefined (no node shim yet), so a bare process.env throws
15
8
  // "process is not defined" and aborts the whole bundle import. envVal() reads
16
9
  // live (picks up a late-installed shim) and never throws.
17
10
  const envVal = (k) => { try { return (typeof process !== 'undefined' && process.env) ? process.env[k] : undefined } catch { return undefined } }
18
11
  const ACPTOAPI_TIMEOUT_MS = Number(envVal('FREDDIE_LLM_TIMEOUT_MS')) || 240000
19
- let _dispatcherSet = false
20
- async function ensureLongTimeoutDispatcher() {
21
- if (_dispatcherSet) return
22
- _dispatcherSet = true
23
- try {
24
- const undici = await import('undici')
25
- // headersTimeout/bodyTimeout 0: tolerate acptoapi's minutes-long walk.
26
- // keepAlive*Timeout 1ms: close the socket right after the response so no
27
- // keep-alive socket lingers between calls.
28
- undici.setGlobalDispatcher(new undici.Agent({
29
- headersTimeout: 0,
30
- bodyTimeout: 0,
31
- keepAliveTimeout: 1,
32
- keepAliveMaxTimeout: 1,
33
- }))
34
- } catch { /* undici not available — rely on AbortController + defaults */ }
35
- }
36
12
 
37
13
  export function getAcptoapiUrl() {
38
- return envVal('FREDDIE_LLM_URL') || 'http://127.0.0.1:4800/v1'
14
+ // Retained for callers that still read a URL for display/logging purposes
15
+ // (the dashboard health row, CLI banner) -- there is no listening port to
16
+ // reach any more; this is informational only.
17
+ return envVal('FREDDIE_LLM_URL') || 'in-process (acptoapi)'
39
18
  }
40
19
 
41
20
  export function getAcptoapiModel() {
42
21
  return envVal('FREDDIE_LLM_MODEL') || 'claude/haiku'
43
22
  }
44
23
 
24
+ let _acptoapi = null
25
+ async function getAcptoapi() {
26
+ if (!_acptoapi) {
27
+ const mod = await import('acptoapi')
28
+ // acptoapi is a CJS package; Node's CJS-to-ESM interop only statically
29
+ // detects a SUBSET of module.exports keys as named exports (witnessed:
30
+ // `chat` is a real named export, `listAllModelsAndQueues` is not, even
31
+ // though both are plain keys on the same module.exports object) -- read
32
+ // through `.default` (the full CJS exports object) so every export is
33
+ // reachable regardless of which subset the interop happened to pick up.
34
+ _acptoapi = mod.default && typeof mod.default === 'object' ? mod.default : mod
35
+ }
36
+ return _acptoapi
37
+ }
38
+
39
+ // In-process call: acptoapi's own chat() walks its provider/model resolution
40
+ // (including a comma-list or named chain for multi-model fallback) with no
41
+ // HTTP hop and no separate listening process -- eliminates the standalone
42
+ // acptoapi.js daemon on :4800 entirely, along with its witnessed failure mode
43
+ // (an uncaught ACP-timeout exception crashing the whole bridge process).
45
44
  export async function callLLM({ messages, tools = [], model, tool_choice, cwd = null } = {}) {
46
- const base = getAcptoapiUrl()
45
+ const acptoapi = await getAcptoapi()
47
46
  const useModel = model || getAcptoapiModel()
48
47
  const hasTools = Array.isArray(tools) && tools.length > 0
49
48
  const adaptedMessages = messages.map(adaptMessage)
@@ -59,46 +58,40 @@ export async function callLLM({ messages, tools = [], model, tool_choice, cwd =
59
58
  if (sysIdx >= 0) adaptedMessages[sysIdx] = { ...adaptedMessages[sysIdx], content: (adaptedMessages[sysIdx].content || '') + cwdNote }
60
59
  else adaptedMessages.unshift({ role: 'system', content: cwdNote.trim() })
61
60
  }
62
- const body = {
63
- model: useModel,
64
- messages: adaptedMessages,
65
- stream: false,
66
- max_tokens: 4096,
67
- }
68
- if (hasTools) body.tools = tools.map(adaptTool)
69
- // Forward a forced tool_choice (e.g. 'required') so a caller can compel a tool
70
- // call on a turn. Only when tools are present; absent -> the model chooses.
71
- if (hasTools && tool_choice) body.tool_choice = tool_choice
72
- const headers = { 'content-type': 'application/json', authorization: 'Bearer none' }
73
- if (hasTools && cwd) headers['x-cwd'] = cwd
74
- // Rely on AbortController timeout. acptoapi v1+ ships CORS + Private
75
- // Network Access headers so cross-origin loopback (gh-pages → localhost)
76
- // succeeds when acptoapi is running. The earlier preemptive loopback
77
- // refusal caused false negatives on reachable endpoints.
78
- await ensureLongTimeoutDispatcher()
79
- const _ac = new AbortController()
80
- const _tid = setTimeout(() => _ac.abort(new Error('acptoapi fetch timeout')), ACPTOAPI_TIMEOUT_MS)
81
- let res
82
- try {
83
- res = await fetch(base.replace(/\/$/, '') + '/chat/completions', {
84
- method: 'POST',
85
- headers,
86
- body: JSON.stringify(body),
87
- signal: _ac.signal,
88
- })
89
- } finally { clearTimeout(_tid) }
90
- if (!res.ok) {
91
- const text = await res.text()
92
- throw new Error(`acptoapi ${res.status}: ${text.slice(0, 400)}`)
93
- }
61
+ // acptoapi's chat() is a plain Promise with no AbortSignal support (each
62
+ // underlying provider has its own internal per-call timeout, e.g. the
63
+ // openai-compat provider's OPENAI_COMPAT_TIMEOUT_MS, default 180s) -- so the
64
+ // overall deadline here is enforced by racing a timeout, not by aborting the
65
+ // in-flight call itself.
66
+ let _timeoutHandle
67
+ const _timeout = new Promise((_, reject) => {
68
+ _timeoutHandle = setTimeout(() => reject(new Error('acptoapi call timeout')), ACPTOAPI_TIMEOUT_MS)
69
+ })
94
70
  let json
95
71
  try {
96
- json = await res.json()
97
- } catch (e) {
98
- throw new Error(`acptoapi ${res.status}: invalid JSON response: ${String(e)}`)
99
- }
72
+ json = await Promise.race([
73
+ acptoapi.chat({
74
+ model: useModel,
75
+ messages: adaptedMessages,
76
+ ...(hasTools ? { tools: tools.map(adaptTool) } : {}),
77
+ max_tokens: 4096,
78
+ }),
79
+ _timeout,
80
+ ])
81
+ } finally { clearTimeout(_timeoutHandle) }
100
82
  log.info('completed', { model: useModel, usage: json.usage })
101
- return adaptResponse(json)
83
+ const adapted = adaptResponse(json)
84
+ // acptoapi's chat()/toParams() does not forward tool_choice to any provider
85
+ // (confirmed: a pre-existing gap, not introduced by going in-process -- the
86
+ // old HTTP path silently dropped it too). Enforce the documented contract
87
+ // client-side: when the caller forced a tool call and none came back, this
88
+ // was previously a SILENT no-op -- log loud so the gap is visible instead of
89
+ // masquerading as "the model chose not to call a tool".
90
+ const forcedToolChoice = tool_choice === 'required' || tool_choice?.type === 'required'
91
+ if (forcedToolChoice && hasTools && !adapted.tool_calls.length) {
92
+ log.warn('tool_choice required but no tool call returned (acptoapi does not enforce tool_choice)', { model: useModel })
93
+ }
94
+ return adapted
102
95
  }
103
96
 
104
97
  function adaptMessage(m) {
@@ -145,21 +138,23 @@ function adaptResponse(r) {
145
138
 
146
139
  function tryParseJson(s) { try { return typeof s === 'string' ? JSON.parse(s) : (s || {}) } catch { return {} } }
147
140
 
141
+ // In-process reachability: acptoapi is a library import now, not a port to
142
+ // dial, so there is no cheap side-channel that answers "is the CONFIGURED
143
+ // model reachable" for an arbitrary provider (witnessed: listAllModelsAndQueues
144
+ // only enumerates configured matrix/queue sources, empty by default; the
145
+ // sampler cache starts empty until something actually probes/fails a
146
+ // provider; chatjimmy -- casey's configured model -- is itself a remote
147
+ // hosted proxy at https://chatjimmy.ai with its own internal model list, not
148
+ // exported from acptoapi's top-level API at all). The only generically
149
+ // correct answer is a real minimal call: send a trivial message with a short
150
+ // timeout and treat success as reachable.
148
151
  export async function isReachable(timeoutMs = 10000) {
149
152
  try {
150
- const controller = new AbortController()
151
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
152
- try {
153
- const res = await fetch(getAcptoapiUrl().replace(/\/$/, '') + '/models', {
154
- headers: { authorization: 'Bearer none' },
155
- signal: controller.signal
156
- })
157
- clearTimeout(timeoutId)
158
- if (!res.ok) return false
159
- const json = await res.json()
160
- return Array.isArray(json.data) && json.data.length > 0
161
- } finally {
162
- clearTimeout(timeoutId)
163
- }
153
+ const acptoapi = await getAcptoapi()
154
+ const result = await Promise.race([
155
+ acptoapi.chat({ model: getAcptoapiModel(), messages: [{ role: 'user', content: 'ping' }], max_tokens: 4 }),
156
+ new Promise((_, reject) => setTimeout(() => reject(new Error('reachability probe timeout')), timeoutMs)),
157
+ ])
158
+ return !!(result && result.choices && result.choices.length)
164
159
  } catch { return false }
165
160
  }