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 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.96",
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.56"
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 { resolveKey } from './credential_sources.js'
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 ACP_BACKENDS = {
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
- function tryParseJson(s) { try { return typeof s === 'string' ? JSON.parse(s) : (s || {}) } catch { return {} } }
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
- async function hasKey(provider) {
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 defaultModel(provider) {
126
- return DEFAULTS[provider] || ''
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
- async function tryChain(entries, input, model) {
130
- const errors = []
131
- for (const pref of entries) {
132
- const p = pref.provider; const m = pref.model || model || input.model || (DEFAULTS[p] || '')
133
- if (!await hasKey(p) || !isAvailable(p)) continue
134
- try { return await sdkChat(p, m, input) } catch (e) { markFailed(p); errors.push(`${p}: ${e.message}`) }
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
- if (errors.length) throw new Error(`chain exhausted: ${errors.join('; ')}`)
137
- throw new Error('chain empty: no available providers')
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 mdl = model || input.model
143
- const queueMatch = typeof mdl === 'string' && /^queue\//.test(mdl)
144
- if (queueMatch) {
145
- const name = mdl.slice('queue/'.length)
146
- const queues = getConfigValue('agent.model_queues', {}) || {}
147
- const entries = Array.isArray(queues[name]) ? queues[name] : null
148
- if (!entries || entries.length === 0) throw new Error(`queue not found or empty: ${name}`)
149
- return await tryChain(entries, input, undefined)
150
- }
151
- const explicitProvider = provider || input.provider
152
-
153
- if (explicitProvider && await hasKey(explicitProvider)) {
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
  }