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 +2 -2
- package/src/agent/acptoapi-bridge.js +72 -77
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "freddie",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
}
|