freddie 0.0.96 → 0.0.98
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/README.md +2 -0
- package/package.json +2 -2
- package/src/agent/llm_resolver.js +35 -176
package/README.md
CHANGED
|
@@ -136,6 +136,8 @@ v0.1.1 complete and witnessed: 12/12 named tests passing, dashboard + website bo
|
|
|
136
136
|
|
|
137
137
|
**LLM providers**: anthropic, openai, groq, openrouter, cerebras, google, mistral, codestral, cloudflare-workers-ai, xai, zai, opencode, nvidia, sambanova, qwen — plus acptoapi localhost bridge. Set `agent.model_preference` in `~/.freddie/config.yaml` for ordered failover with exponential backoff.
|
|
138
138
|
|
|
139
|
+
**Model availability matrix**: `scripts/build-model-availability.js` cross-probes every (provider × model × access_mode) cell across 7 modes (`direct_api`, `acptoapi_passthrough`, `freddie_v1`, `kilo_acp`, `opencode_acp`, `claude_cli`, `freddie_agent_loop`). Sampler-aware on both `probeDirect` and `probeAgentLoop` — failures feed acptoapi's per-provider exponential backoff (5-step 30s→480s). Output: `.gm/model-availability.json` with `{timestamp, config, daemons, providers[].models[].modes{}, sampler, summary}`. Three dashboard endpoints in `plugins/gui-models-discover/plugin.js`: `GET /api/models/availability` (full JSON or 404), `GET /api/models/availability/summary` (timestamp+daemons+summary only), `POST /api/models/availability/rebuild` (202 background spawn). See AGENTS.md for full schema + skipped-reason taxonomy.
|
|
140
|
+
|
|
139
141
|
What's not in the box yet (residual, see AGENTS.md): real credentials per platform / memory backend; modal / daytona / singularity environments; bedrock / codex provider adapters.
|
|
140
142
|
|
|
141
143
|
## Testing
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "freddie",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.98",
|
|
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
|
"xstate": "^5.31.0",
|
|
28
28
|
"zod": "^4.0.0",
|
|
29
29
|
"anentrypoint-design": "^0.0.94",
|
|
30
|
-
"acptoapi": "^1.0.
|
|
30
|
+
"acptoapi": "^1.0.59"
|
|
31
31
|
},
|
|
32
32
|
"optionalDependencies": {
|
|
33
33
|
"@libsql/darwin-arm64": "0.3.19",
|
|
@@ -1,198 +1,57 @@
|
|
|
1
1
|
import { createRequire } from 'module'
|
|
2
|
-
import { callLLM as acptoapiCall, isReachable as acptoapiReachable } from './acptoapi-bridge.js'
|
|
3
|
-
import { isAvailable, markFailed, getStatus } from './model-sampler.js'
|
|
4
2
|
import { getConfigValue } from '../config.js'
|
|
5
|
-
import {
|
|
6
|
-
import { matrixUsable } from './model-matrix.js'
|
|
3
|
+
import { MATRIX_FILE } from './model-matrix.js'
|
|
7
4
|
export { matrixUsable } from './model-matrix.js'
|
|
8
5
|
|
|
9
6
|
const _require = createRequire(import.meta.url)
|
|
10
7
|
const sdk = _require('acptoapi')
|
|
11
|
-
const { streamClaude, CLAUDE_DEFAULT } = _require('acptoapi/lib/claude-client')
|
|
12
|
-
|
|
13
8
|
export const PROVIDER_KEYS = sdk.PROVIDER_KEYS
|
|
14
9
|
export const DEFAULTS = sdk.PROVIDER_DEFAULTS
|
|
15
10
|
|
|
16
|
-
const
|
|
17
|
-
kilo: { base: 'http://localhost:4780', providerID: 'kilo', defaultModel: 'openrouter/free' },
|
|
18
|
-
opencode: { base: 'http://localhost:4790', providerID: 'opencode', defaultModel: 'minimax-m2.5-free' },
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
async function claudeCliChat(model, input) {
|
|
22
|
-
const userMsg = input.messages.filter(m => m.role === 'user').slice(-1)[0]?.content
|
|
23
|
-
const systemMsg = input.messages.filter(m => m.role === 'system').map(m => m.content).join('\n\n') || undefined
|
|
24
|
-
const prompt = typeof userMsg === 'string' ? userMsg : JSON.stringify(userMsg || '')
|
|
25
|
-
let content = ''; const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), 120000)
|
|
26
|
-
try {
|
|
27
|
-
for await (const ev of streamClaude({ prompt, model: model || CLAUDE_DEFAULT, systemPrompt: systemMsg, signal: ctrl.signal })) {
|
|
28
|
-
if (ev.type === 'assistant' && Array.isArray(ev.message?.content)) for (const part of ev.message.content) { if (part.type === 'text' && part.text) content += part.text }
|
|
29
|
-
if (ev.type === 'result' && typeof ev.result === 'string') content = ev.result
|
|
30
|
-
}
|
|
31
|
-
} finally { clearTimeout(t) }
|
|
32
|
-
return { content: content.trim(), tool_calls: [], raw: { provider: 'claude-cli', model } }
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
async function acpChat(prefix, model, input) {
|
|
36
|
-
const b = ACP_BACKENDS[prefix]; if (!b) throw new Error(`unknown acp backend: ${prefix}`)
|
|
37
|
-
const sessRes = await fetch(`${b.base}/session`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', signal: AbortSignal.timeout(5000) })
|
|
38
|
-
if (!sessRes.ok) throw new Error(`ACP ${prefix} /session ${sessRes.status}`)
|
|
39
|
-
const sessionId = (await sessRes.json()).id
|
|
40
|
-
const userMsg = input.messages.filter(m => m.role === 'user').slice(-1)[0]?.content || ''
|
|
41
|
-
const body = { parts: [{ type: 'text', text: String(userMsg) }], model: { providerID: b.providerID, modelID: model || b.defaultModel } }
|
|
42
|
-
const evRes = await fetch(`${b.base}/event`, { method: 'GET', signal: AbortSignal.timeout(120000) })
|
|
43
|
-
if (!evRes.ok) throw new Error(`ACP ${prefix} /event ${evRes.status}`)
|
|
44
|
-
const msgRes = await fetch(`${b.base}/session/${sessionId}/message`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(120000) })
|
|
45
|
-
if (!msgRes.ok) throw new Error(`ACP ${prefix} /message ${msgRes.status}: ${(await msgRes.text()).slice(0,200)}`)
|
|
46
|
-
let content = ''; let sawAssistantText = false
|
|
47
|
-
const reader = evRes.body.getReader(); const dec = new TextDecoder(); let buf = ''
|
|
48
|
-
while (true) {
|
|
49
|
-
const { value, done } = await reader.read(); if (done) break
|
|
50
|
-
buf += dec.decode(value, { stream: true }); let idx
|
|
51
|
-
while ((idx = buf.indexOf('\n\n')) >= 0) {
|
|
52
|
-
const raw = buf.slice(0, idx); buf = buf.slice(idx + 2)
|
|
53
|
-
if (!raw.startsWith('data: ')) continue
|
|
54
|
-
try { const ev = JSON.parse(raw.slice(6))
|
|
55
|
-
if (ev.properties?.sessionID && ev.properties.sessionID !== sessionId) continue
|
|
56
|
-
if (ev.type === 'message.part.updated' && ev.properties?.part?.type === 'text' && ev.properties.part.text) { content = ev.properties.part.text; sawAssistantText = true }
|
|
57
|
-
if (ev.type === 'session.error') throw new Error(`ACP ${prefix} session.error: ${JSON.stringify(ev.properties?.error || {}).slice(0,200)}`)
|
|
58
|
-
if (ev.type === 'session.idle') return { content: content.trim(), tool_calls: [], raw: { provider: prefix, model } }
|
|
59
|
-
} catch (e) { if (/session.error/.test(e.message)) throw e }
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
return { content: content.trim(), tool_calls: [], raw: { provider: prefix, model } }
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function toOpenAITools(schemas) {
|
|
66
|
-
if (!schemas?.length) return undefined
|
|
67
|
-
return schemas.map(s => ({ type: 'function', function: { name: s.name, description: s.description || '', parameters: s.parameters || { type: 'object', properties: {} } } }))
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function toOpenAIMessages(messages) {
|
|
71
|
-
return messages.map(m => {
|
|
72
|
-
if (m.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) {
|
|
73
|
-
return { role: 'assistant', content: m.content || '', tool_calls: m.tool_calls.map(tc => ({ id: tc.id, type: 'function', function: { name: tc.name || tc.function?.name, arguments: typeof (tc.arguments || tc.function?.arguments) === 'string' ? (tc.arguments || tc.function?.arguments) : JSON.stringify(tc.arguments || tc.function?.arguments || {}) } })) }
|
|
74
|
-
}
|
|
75
|
-
if (m.role === 'tool') return { role: 'tool', tool_call_id: m.tool_call_id, content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content) }
|
|
76
|
-
return m
|
|
77
|
-
})
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
async function directOpenAICompatChat(url, apiKey, model, messages, tools) {
|
|
81
|
-
const body = { model, messages: toOpenAIMessages(messages), ...(tools?.length ? { tools } : {}) }
|
|
82
|
-
const res = await fetch(url, {
|
|
83
|
-
method: 'POST',
|
|
84
|
-
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
|
|
85
|
-
body: JSON.stringify(body),
|
|
86
|
-
signal: AbortSignal.timeout(120000),
|
|
87
|
-
})
|
|
88
|
-
if (!res.ok) { const t = await res.text(); throw new Error(`${res.status} ${t.slice(0, 200)}`) }
|
|
89
|
-
return res.json()
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
async function sdkChat(provider, model, input) {
|
|
93
|
-
if (provider === 'claude-cli') return await claudeCliChat(model, input)
|
|
94
|
-
if (provider === 'kilo' || provider === 'opencode') return await acpChat(provider, model, input)
|
|
95
|
-
const { resolveModel } = sdk
|
|
96
|
-
const r = resolveModel(`${provider}/${model}`)
|
|
97
|
-
const resolved = await resolveKey(provider).catch(() => ({ value: null }))
|
|
98
|
-
const apiKey = resolved.value || (r.env ? process.env[r.env] : undefined)
|
|
99
|
-
const openaiTools = toOpenAITools(input.tools)
|
|
100
|
-
let result
|
|
101
|
-
if (r.provider === 'openai-compat') {
|
|
102
|
-
result = await directOpenAICompatChat(r.url, apiKey, r.model, input.messages, openaiTools)
|
|
103
|
-
} else {
|
|
104
|
-
const { buffer: sdkBuffer } = sdk
|
|
105
|
-
result = await sdkBuffer({ from: null, to: 'openai', provider: r.provider, model: r.model, messages: toOpenAIMessages(input.messages), apiKey, ...(openaiTools ? { tools: openaiTools } : {}) })
|
|
106
|
-
}
|
|
107
|
-
const choice = result?.choices?.[0]?.message || {}
|
|
108
|
-
const content = typeof choice.content === 'string' ? choice.content : ''
|
|
109
|
-
const tool_calls = Array.isArray(choice.tool_calls)
|
|
110
|
-
? choice.tool_calls.map(tc => ({ id: tc.id, name: tc.function?.name, arguments: tryParseJson(tc.function?.arguments) }))
|
|
111
|
-
: []
|
|
112
|
-
return { content, tool_calls, raw: result }
|
|
113
|
-
}
|
|
11
|
+
const toTools = s => s?.length ? s.map(t => ({ type: 'function', function: { name: t.name, description: t.description || '', parameters: t.parameters || { type: 'object', properties: {} } } })) : undefined
|
|
114
12
|
|
|
115
|
-
|
|
13
|
+
const toMsgs = ms => ms.map(m => {
|
|
14
|
+
if (m.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) return { role: 'assistant', content: m.content || '', tool_calls: m.tool_calls.map(tc => ({ id: tc.id, type: 'function', function: { name: tc.name || tc.function?.name, arguments: typeof (tc.arguments || tc.function?.arguments) === 'string' ? (tc.arguments || tc.function?.arguments) : JSON.stringify(tc.arguments || tc.function?.arguments || {}) } })) }
|
|
15
|
+
if (m.role === 'tool') return { role: 'tool', tool_call_id: m.tool_call_id, content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content) }
|
|
16
|
+
return m
|
|
17
|
+
})
|
|
116
18
|
|
|
117
|
-
|
|
118
|
-
if (provider === 'claude-cli' || provider === 'kilo' || provider === 'opencode') return true
|
|
119
|
-
const resolved = await resolveKey(provider).catch(() => ({ value: null }))
|
|
120
|
-
if (!resolved.value) return false
|
|
121
|
-
if (provider === 'cloudflare' && !process.env.CLOUDFLARE_ACCOUNT_ID) return false
|
|
122
|
-
return true
|
|
123
|
-
}
|
|
19
|
+
const tryJson = s => { try { return typeof s === 'string' ? JSON.parse(s) : (s || {}) } catch { return {} } }
|
|
124
20
|
|
|
125
|
-
function
|
|
126
|
-
|
|
21
|
+
function adapt(result) {
|
|
22
|
+
const c = result?.choices?.[0]?.message || {}
|
|
23
|
+
return { content: typeof c.content === 'string' ? c.content : '', tool_calls: Array.isArray(c.tool_calls) ? c.tool_calls.map(tc => ({ id: tc.id, name: tc.function?.name, arguments: tryJson(tc.function?.arguments) })) : [], raw: result }
|
|
127
24
|
}
|
|
128
25
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
26
|
+
function buildModel({ provider, model, inputModel }) {
|
|
27
|
+
if (provider) return `${provider}/${model || DEFAULTS[provider] || ''}`.replace(/\/$/, '')
|
|
28
|
+
if (model) return model
|
|
29
|
+
if (inputModel) return inputModel
|
|
30
|
+
const pref = getConfigValue('agent.model_preference', [])
|
|
31
|
+
if (Array.isArray(pref) && pref.length) {
|
|
32
|
+
const links = pref.map(p => `${p.provider}/${p.model || DEFAULTS[p.provider] || ''}`.replace(/\/$/, '')).filter(s => s.includes('/'))
|
|
33
|
+
if (links.length) return links.join(', ')
|
|
135
34
|
}
|
|
136
|
-
|
|
137
|
-
|
|
35
|
+
const auto = sdk.buildAutoChain(undefined)
|
|
36
|
+
const keyed = Array.isArray(auto) ? auto.filter(l => { const p = l.model.split('/')[0]; const env = PROVIDER_KEYS[p]; return env && process.env[env] }) : []
|
|
37
|
+
if (keyed.length) return keyed.map(l => l.model).join(', ')
|
|
38
|
+
return null
|
|
138
39
|
}
|
|
139
40
|
|
|
140
41
|
export function resolveCallLLM({ provider, model } = {}) {
|
|
141
42
|
return async (input) => {
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
return
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const m = model || input.model || defaultModel(explicitProvider)
|
|
155
|
-
if (!isAvailable(explicitProvider)) {
|
|
156
|
-
const status = getStatus().map(s => `${s.provider}(ok=${s.ok},fails=${s.failCount})`).join(', ')
|
|
157
|
-
throw new Error(`provider ${explicitProvider} is in backoff | sampler: ${status}`)
|
|
158
|
-
}
|
|
159
|
-
try {
|
|
160
|
-
return await sdkChat(explicitProvider, m, input)
|
|
161
|
-
} catch (e) {
|
|
162
|
-
markFailed(explicitProvider)
|
|
163
|
-
throw e
|
|
164
|
-
}
|
|
43
|
+
const m = buildModel({ provider, model, inputModel: input.model })
|
|
44
|
+
if (!m) {
|
|
45
|
+
const status = sdk.getStatus().map(s => `${s.provider}(ok=${s.ok},fails=${s.failCount})`).join(', ')
|
|
46
|
+
throw new Error('no LLM backend reachable: set a provider API key or start acptoapi (http://127.0.0.1:4800/v1)' + (status ? ' | sampler: ' + status : ''))
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const r = await sdk.chat({ model: m, messages: toMsgs(input.messages), tools: toTools(input.tools), queuesMap: getConfigValue('agent.model_queues', {}) || {}, matrixSource: process.env.FREDDIE_MATRIX_URL || MATRIX_FILE, onFallback: input.onFallback, output: 'openai' })
|
|
50
|
+
return adapt(r)
|
|
51
|
+
} catch (e) {
|
|
52
|
+
if (/queue not found or empty/i.test(e.message)) throw e
|
|
53
|
+
if (e.chainHistory || /All chain links failed|chain\(\) requires/i.test(e.message)) throw new Error(`chain exhausted: ${(e.attempted || []).map(a => `${a.model}:${a.reason || 'ok'}`).join('; ') || e.message}`)
|
|
54
|
+
throw e
|
|
165
55
|
}
|
|
166
|
-
|
|
167
|
-
if (await acptoapiReachable()) {
|
|
168
|
-
return await acptoapiCall({ ...input, model: model || input.model })
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
const preference = getConfigValue('agent.model_preference', [])
|
|
172
|
-
if (Array.isArray(preference) && preference.length > 0) {
|
|
173
|
-
try { return await tryChain(preference, input, model) } catch (e) { if (!/chain empty/.test(e.message)) throw e }
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const links = sdk.buildAutoChain(model || input.model)
|
|
177
|
-
const availableLinks = (await Promise.all(links.map(async l => {
|
|
178
|
-
const prefix = l.model.split('/')[0]
|
|
179
|
-
if (!(await hasKey(prefix)) || !isAvailable(prefix)) return null
|
|
180
|
-
const mu = matrixUsable(prefix, l.model.replace(/^[^/]+\//, ''))
|
|
181
|
-
if (mu === false) return null
|
|
182
|
-
return l
|
|
183
|
-
}))).filter(Boolean)
|
|
184
|
-
|
|
185
|
-
for (const link of availableLinks) {
|
|
186
|
-
const prefix = link.model.split('/')[0]
|
|
187
|
-
const m = link.model.replace(/^[^/]+\//, '')
|
|
188
|
-
try {
|
|
189
|
-
return await sdkChat(prefix, m, input)
|
|
190
|
-
} catch (e) {
|
|
191
|
-
markFailed(prefix)
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const status = getStatus().map(s => `${s.provider}(ok=${s.ok},fails=${s.failCount})`).join(', ')
|
|
196
|
-
throw new Error('no LLM backend reachable: set a provider API key or start acptoapi (http://127.0.0.1:4800/v1)' + (status ? ' | sampler: ' + status : ''))
|
|
197
56
|
}
|
|
198
57
|
}
|