freddie 0.0.84 → 0.0.86

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.84",
3
+ "version": "0.0.86",
4
4
  "type": "module",
5
5
  "description": "Open JS agent harness built on pi-mono, floosie, xstate, and anentrypoint-design",
6
6
  "bin": {
@@ -26,8 +26,8 @@
26
26
  "plugsdk": "^1.0.15",
27
27
  "xstate": "^5.31.0",
28
28
  "zod": "^4.0.0",
29
- "anentrypoint-design": "^0.0.91",
30
- "acptoapi": "^1.0.50"
29
+ "anentrypoint-design": "^0.0.93",
30
+ "acptoapi": "^1.0.51"
31
31
  },
32
32
  "optionalDependencies": {
33
33
  "@libsql/darwin-arm64": "0.3.19",
@@ -1,4 +1,5 @@
1
1
  import { resolveCallLLM } from '../../src/agent/llm_resolver.js'
2
+ import { flattenForOpenAI } from '../../src/agent/model-discovery.js'
2
3
  import { logger } from '../../src/observability/log.js'
3
4
 
4
5
  const log = logger('gui-llm-passthrough')
@@ -7,7 +8,9 @@ export default {
7
8
  name: 'gui-llm-passthrough', surfaces: 'gui',
8
9
  register({ gui }) {
9
10
  gui.route('GET', '/v1/models', (_, res) => {
10
- res.json({ object: 'list', data: [{ id: 'freddie/auto', object: 'model', created: Math.floor(Date.now() / 1000), owned_by: 'freddie' }] })
11
+ const data = flattenForOpenAI()
12
+ if (data.length === 0) data.push({ id: 'freddie/auto', object: 'model', created: Math.floor(Date.now() / 1000), owned_by: 'freddie' })
13
+ res.json({ object: 'list', data })
11
14
  })
12
15
  gui.route('POST', '/v1/chat/completions', async (req, res) => {
13
16
  const { model, messages, tools, stream } = req.body || {}
@@ -1,6 +1,7 @@
1
1
  import { discoverAndPersist, listKnownProviders } from '../../src/agent/model-discovery.js'
2
2
  import { PROVIDER_KEYS, DEFAULTS } from '../../src/agent/llm_resolver.js'
3
- import { getConfigValue } from '../../src/config.js'
3
+ import { getConfigValue, saveConfigValue } from '../../src/config.js'
4
+ import { getStatus } from '../../src/agent/model-sampler.js'
4
5
 
5
6
  export default {
6
7
  name: 'gui-models-discover', surfaces: 'gui',
@@ -11,5 +12,21 @@ export default {
11
12
  try { const provider = req.body?.provider; const result = await discoverAndPersist({ provider }); res.json(result) }
12
13
  catch (e) { res.status(500).json({ error: String(e.message || e) }) }
13
14
  })
15
+ gui.route('GET', '/api/models/queues', (_, res) => res.json(getConfigValue('agent.model_queues', {}) || {}))
16
+ gui.route('POST', '/api/models/queues', (req, res) => {
17
+ const { name, entries } = req.body || {}
18
+ if (!name || !Array.isArray(entries)) return res.status(400).json({ error: 'name and entries[] required' })
19
+ const queues = getConfigValue('agent.model_queues', {}) || {}
20
+ queues[name] = entries
21
+ saveConfigValue('agent.model_queues', queues)
22
+ res.json({ name, entries })
23
+ })
24
+ gui.route('DELETE', '/api/models/queues/:name', (req, res) => {
25
+ const queues = getConfigValue('agent.model_queues', {}) || {}
26
+ delete queues[req.params.name]
27
+ saveConfigValue('agent.model_queues', queues)
28
+ res.json({ ok: true })
29
+ })
30
+ gui.route('GET', '/api/models/sampler', (_, res) => res.json({ status: getStatus() }))
14
31
  },
15
32
  }
@@ -6,10 +6,60 @@ import { resolveKey } from './credential_sources.js'
6
6
 
7
7
  const _require = createRequire(import.meta.url)
8
8
  const sdk = _require('acptoapi')
9
+ const { streamClaude, CLAUDE_DEFAULT } = _require('acptoapi/lib/claude-client')
9
10
 
10
11
  export const PROVIDER_KEYS = sdk.PROVIDER_KEYS
11
12
  export const DEFAULTS = sdk.PROVIDER_DEFAULTS
12
13
 
14
+ const ACP_BACKENDS = {
15
+ kilo: { base: 'http://localhost:4780', providerID: 'kilo', defaultModel: 'x-ai/grok-code-fast-1:optimized:free' },
16
+ opencode: { base: 'http://localhost:4790', providerID: 'opencode', defaultModel: 'minimax-m2.5-free' },
17
+ }
18
+
19
+ async function claudeCliChat(model, input) {
20
+ const userMsg = input.messages.filter(m => m.role === 'user').slice(-1)[0]?.content
21
+ const systemMsg = input.messages.filter(m => m.role === 'system').map(m => m.content).join('\n\n') || undefined
22
+ const prompt = typeof userMsg === 'string' ? userMsg : JSON.stringify(userMsg || '')
23
+ let content = ''; const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), 120000)
24
+ try {
25
+ for await (const ev of streamClaude({ prompt, model: model || CLAUDE_DEFAULT, systemPrompt: systemMsg, signal: ctrl.signal })) {
26
+ 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 }
27
+ if (ev.type === 'result' && typeof ev.result === 'string') content = ev.result
28
+ }
29
+ } finally { clearTimeout(t) }
30
+ return { content: content.trim(), tool_calls: [], raw: { provider: 'claude-cli', model } }
31
+ }
32
+
33
+ async function acpChat(prefix, model, input) {
34
+ const b = ACP_BACKENDS[prefix]; if (!b) throw new Error(`unknown acp backend: ${prefix}`)
35
+ const sessRes = await fetch(`${b.base}/session`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', signal: AbortSignal.timeout(5000) })
36
+ if (!sessRes.ok) throw new Error(`ACP ${prefix} /session ${sessRes.status}`)
37
+ const sessionId = (await sessRes.json()).id
38
+ const userMsg = input.messages.filter(m => m.role === 'user').slice(-1)[0]?.content || ''
39
+ const body = { parts: [{ type: 'text', text: String(userMsg) }] }
40
+ if (b.providerID === 'opencode') body.model = { providerID: 'opencode', modelID: model || b.defaultModel }
41
+ else { body.providerID = 'kilo'; body.modelID = model || b.defaultModel }
42
+ await fetch(`${b.base}/session/${sessionId}/message`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(120000) })
43
+ let content = ''
44
+ const evRes = await fetch(`${b.base}/event`, { method: 'GET', signal: AbortSignal.timeout(120000) })
45
+ if (!evRes.ok) throw new Error(`ACP ${prefix} /event ${evRes.status}`)
46
+ const reader = evRes.body.getReader(); const dec = new TextDecoder(); let buf = ''
47
+ while (true) {
48
+ const { value, done } = await reader.read(); if (done) break
49
+ buf += dec.decode(value, { stream: true }); let idx
50
+ while ((idx = buf.indexOf('\n\n')) >= 0) {
51
+ const raw = buf.slice(0, idx); buf = buf.slice(idx + 2)
52
+ if (!raw.startsWith('data: ')) continue
53
+ try { const ev = JSON.parse(raw.slice(6))
54
+ if (ev.properties?.sessionID && ev.properties.sessionID !== sessionId) continue
55
+ if (ev.properties?.part?.type === 'text' && ev.properties.part.text) content += ev.properties.part.text
56
+ if (ev.event === 'session.complete' || ev.properties?.complete) return { content: content.trim(), tool_calls: [], raw: { provider: prefix, model } }
57
+ } catch {}
58
+ }
59
+ }
60
+ return { content: content.trim(), tool_calls: [], raw: { provider: prefix, model } }
61
+ }
62
+
13
63
  function toOpenAITools(schemas) {
14
64
  if (!schemas?.length) return undefined
15
65
  return schemas.map(s => ({ type: 'function', function: { name: s.name, description: s.description || '', parameters: s.parameters || { type: 'object', properties: {} } } }))
@@ -28,6 +78,8 @@ async function directOpenAICompatChat(url, apiKey, model, messages, tools) {
28
78
  }
29
79
 
30
80
  async function sdkChat(provider, model, input) {
81
+ if (provider === 'claude-cli') return await claudeCliChat(model, input)
82
+ if (provider === 'kilo' || provider === 'opencode') return await acpChat(provider, model, input)
31
83
  const { resolveModel } = sdk
32
84
  const r = resolveModel(`${provider}/${model}`)
33
85
  const resolved = await resolveKey(provider).catch(() => ({ value: null }))
@@ -51,6 +103,7 @@ async function sdkChat(provider, model, input) {
51
103
  function tryParseJson(s) { try { return typeof s === 'string' ? JSON.parse(s) : (s || {}) } catch { return {} } }
52
104
 
53
105
  async function hasKey(provider) {
106
+ if (provider === 'claude-cli' || provider === 'kilo' || provider === 'opencode') return true
54
107
  const resolved = await resolveKey(provider).catch(() => ({ value: null }))
55
108
  return !!resolved.value
56
109
  }
@@ -59,8 +112,28 @@ function defaultModel(provider) {
59
112
  return DEFAULTS[provider] || ''
60
113
  }
61
114
 
115
+ async function tryChain(entries, input, model) {
116
+ const errors = []
117
+ for (const pref of entries) {
118
+ const p = pref.provider; const m = pref.model || model || input.model || (DEFAULTS[p] || '')
119
+ if (!await hasKey(p) || !isAvailable(p)) continue
120
+ try { return await sdkChat(p, m, input) } catch (e) { markFailed(p); errors.push(`${p}: ${e.message}`) }
121
+ }
122
+ if (errors.length) throw new Error(`chain exhausted: ${errors.join('; ')}`)
123
+ throw new Error('chain empty: no available providers')
124
+ }
125
+
62
126
  export function resolveCallLLM({ provider, model } = {}) {
63
127
  return async (input) => {
128
+ const mdl = model || input.model
129
+ const queueMatch = typeof mdl === 'string' && /^queue\//.test(mdl)
130
+ if (queueMatch) {
131
+ const name = mdl.slice('queue/'.length)
132
+ const queues = getConfigValue('agent.model_queues', {}) || {}
133
+ const entries = Array.isArray(queues[name]) ? queues[name] : null
134
+ if (!entries || entries.length === 0) throw new Error(`queue not found or empty: ${name}`)
135
+ return await tryChain(entries, input, undefined)
136
+ }
64
137
  const explicitProvider = provider || input.provider
65
138
 
66
139
  if (explicitProvider && await hasKey(explicitProvider)) {
@@ -1,44 +1,94 @@
1
1
  import { createRequire } from 'module'
2
- import { PROVIDER_KEYS } from './llm_resolver.js'
3
2
  import { resolveKey } from './credential_sources.js'
4
3
  import { saveConfigValue, getConfigValue } from '../config.js'
5
4
  import { logger } from '../observability/log.js'
6
5
 
7
6
  const _require = createRequire(import.meta.url)
8
- const sdk = _require('acptoapi')
7
+ const { createModelProber } = _require('acptoapi/lib/model-prober')
8
+ const { BRANDS } = _require('acptoapi/lib/openai-brands')
9
9
  const log = logger('model-discovery')
10
10
 
11
- const ENDPOINTS = {
12
- anthropic: { url: 'https://api.anthropic.com/v1/models', auth: k => ({ 'x-api-key': k, 'anthropic-version': '2023-06-01' }), pick: j => (j.data || []).map(m => m.id) },
13
- openai: { url: 'https://api.openai.com/v1/models', auth: k => ({ authorization: `Bearer ${k}` }), pick: j => (j.data || []).map(m => m.id) },
14
- openrouter: { url: 'https://openrouter.ai/api/v1/models', auth: k => ({ authorization: `Bearer ${k}` }), pick: j => (j.data || []).map(m => m.id) },
15
- groq: { url: 'https://api.groq.com/openai/v1/models', auth: k => ({ authorization: `Bearer ${k}` }), pick: j => (j.data || []).map(m => m.id) },
16
- xai: { url: 'https://api.x.ai/v1/models', auth: k => ({ authorization: `Bearer ${k}` }), pick: j => (j.data || []).map(m => m.id) },
17
- gemini: { url: 'https://generativelanguage.googleapis.com/v1beta/models', auth: () => ({}), keyParam: 'key', pick: j => (j.models || []).map(m => m.name.replace(/^models\//, '')) },
11
+ const EXTRA = {
12
+ anthropic: { url: 'https://api.anthropic.com/v1/models', envName: 'ANTHROPIC_API_KEY', auth: k => ({ 'x-api-key': k, 'anthropic-version': '2023-06-01' }), pick: j => (j.data || []).map(m => m.id) },
13
+ gemini: { url: 'https://generativelanguage.googleapis.com/v1beta/models', envName: 'GOOGLE_API_KEY', keyParam: 'key', auth: () => ({}), pick: j => (j.models || []).map(m => (m.name || '').replace(/^models\//, '')).filter(Boolean) },
14
+ ollama: { url: 'http://localhost:11434/api/tags', envName: null, auth: () => ({}), pick: j => (j.models || []).map(m => m.name || m.model).filter(Boolean) },
18
15
  }
16
+ const ACP_BACKENDS = {
17
+ kilo: { url: 'http://localhost:4780/session', staticModels: ['x-ai/grok-code-fast-1:optimized:free'] },
18
+ opencode: { url: 'http://localhost:4790/session', staticModels: ['minimax-m2.5-free'] },
19
+ }
20
+ const CLI_BACKENDS = {
21
+ 'claude-cli': { models: ['claude-haiku-4-5', 'claude-sonnet-4-6', 'claude-opus-4-7', 'haiku', 'sonnet', 'opus'] },
22
+ }
23
+
24
+ const prober = createModelProber()
19
25
 
20
- async function probeProvider(provider) {
21
- const ep = ENDPOINTS[provider]
22
- if (!ep) return { provider, error: 'no_probe_endpoint' }
26
+ async function probeBrand(provider) {
23
27
  const resolved = await resolveKey(provider).catch(() => ({ value: null }))
24
- const key = resolved.value || process.env[PROVIDER_KEYS[provider]]
28
+ const brand = BRANDS[provider]
29
+ const envKey = brand?.envKey
30
+ const key = resolved.value || (envKey ? process.env[envKey] : undefined)
25
31
  if (!key) return { provider, error: 'no_key' }
32
+ try {
33
+ const r = await prober.probe(provider, key)
34
+ if (r.error) return { provider, error: r.error }
35
+ return { provider, models: r.models || [], last_ok_at: r.ts }
36
+ } catch (e) { return { provider, error: String(e.message || e) } }
37
+ }
38
+
39
+ async function probeExtra(provider) {
40
+ const ep = EXTRA[provider]
41
+ if (!ep) return { provider, error: 'no_extra_endpoint' }
42
+ const resolved = await resolveKey(provider).catch(() => ({ value: null }))
43
+ const key = resolved.value || (ep.envName ? process.env[ep.envName] : undefined)
44
+ if (!key && provider !== 'ollama') return { provider, error: 'no_key' }
26
45
  try {
27
46
  const url = ep.keyParam ? `${ep.url}?${ep.keyParam}=${encodeURIComponent(key)}` : ep.url
28
47
  const headers = ep.auth(key)
29
- const res = await fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(15000) })
48
+ const res = await fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(8000) })
30
49
  if (!res.ok) { const t = await res.text(); return { provider, error: `${res.status}: ${t.slice(0, 200)}` } }
31
50
  const json = await res.json()
32
- const models = ep.pick(json) || []
33
- return { provider, models, last_ok_at: Date.now() }
34
- } catch (e) {
35
- return { provider, error: String(e.message || e) }
36
- }
51
+ return { provider, models: ep.pick(json) || [], last_ok_at: Date.now() }
52
+ } catch (e) { return { provider, error: String(e.message || e) } }
53
+ }
54
+
55
+ async function probeAcp(provider) {
56
+ const b = ACP_BACKENDS[provider]
57
+ try {
58
+ const r = await fetch(b.url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', signal: AbortSignal.timeout(2000) })
59
+ if (!r.ok) return { provider, error: `${r.status}`, models: b.staticModels }
60
+ return { provider, models: b.staticModels, last_ok_at: Date.now() }
61
+ } catch (e) { return { provider, error: String(e.message || e), models: b.staticModels } }
62
+ }
63
+
64
+ async function probeCli(provider) {
65
+ if (provider !== 'claude-cli') return { provider, error: 'unknown_cli', models: [] }
66
+ try {
67
+ const { spawn } = await import('node:child_process')
68
+ const ok = await new Promise(res => {
69
+ const p = spawn('claude', ['--version'], { stdio: 'ignore', shell: false })
70
+ const t = setTimeout(() => { p.kill(); res(false) }, 3000)
71
+ p.on('exit', c => { clearTimeout(t); res(c === 0) })
72
+ p.on('error', () => { clearTimeout(t); res(false) })
73
+ })
74
+ if (!ok) return { provider, error: 'claude_cli_not_available', models: [] }
75
+ return { provider, models: CLI_BACKENDS['claude-cli'].models, last_ok_at: Date.now() }
76
+ } catch (e) { return { provider, error: String(e.message || e), models: [] } }
77
+ }
78
+
79
+ export function listKnownProviders() {
80
+ return [...Object.keys(BRANDS), ...Object.keys(EXTRA), ...Object.keys(ACP_BACKENDS), ...Object.keys(CLI_BACKENDS)]
37
81
  }
38
82
 
39
83
  export async function discoverModels({ provider } = {}) {
40
- const providers = provider ? [provider] : Object.keys(ENDPOINTS)
41
- const results = await Promise.all(providers.map(p => probeProvider(p)))
84
+ const providers = provider ? [provider] : listKnownProviders()
85
+ const results = await Promise.all(providers.map(p => {
86
+ if (BRANDS[p]) return probeBrand(p)
87
+ if (EXTRA[p]) return probeExtra(p)
88
+ if (ACP_BACKENDS[p]) return probeAcp(p)
89
+ if (CLI_BACKENDS[p]) return probeCli(p)
90
+ return Promise.resolve({ provider: p, error: 'unknown_provider' })
91
+ }))
42
92
  const byProvider = {}
43
93
  for (const r of results) byProvider[r.provider] = r
44
94
  log.info('discovered', { count: results.length, ok: results.filter(r => !r.error).length })
@@ -51,12 +101,23 @@ export async function discoverAndPersist({ provider } = {}) {
51
101
  const merged = { ...existing }
52
102
  for (const [p, r] of Object.entries(result)) {
53
103
  if (!r.error) merged[p] = { models: r.models, last_ok_at: r.last_ok_at }
54
- else merged[p] = { ...(existing[p] || {}), error: r.error, last_error_at: Date.now() }
104
+ else merged[p] = { ...(existing[p] || {}), models: r.models || (existing[p]?.models) || [], error: r.error, last_error_at: Date.now() }
55
105
  }
56
106
  saveConfigValue('agent.discovered_models', merged)
57
107
  return result
58
108
  }
59
109
 
60
- export function listKnownProviders() {
61
- return Object.keys(ENDPOINTS)
110
+ export function flattenForOpenAI() {
111
+ const cached = getConfigValue('agent.discovered_models', {}) || {}
112
+ const queues = getConfigValue('agent.model_queues', {}) || {}
113
+ const data = []
114
+ for (const [provider, info] of Object.entries(cached)) {
115
+ for (const model of (info.models || [])) {
116
+ data.push({ id: `${provider}/${model}`, object: 'model', created: Math.floor((info.last_ok_at || Date.now()) / 1000), owned_by: provider })
117
+ }
118
+ }
119
+ for (const name of Object.keys(queues)) {
120
+ data.push({ id: `queue/${name}`, object: 'model', created: Math.floor(Date.now() / 1000), owned_by: 'queue' })
121
+ }
122
+ return data
62
123
  }
package/src/config.js CHANGED
@@ -4,9 +4,9 @@ import yaml from 'js-yaml'
4
4
  import { getFreddieHome } from './home.js'
5
5
 
6
6
  export const DEFAULT_CONFIG = {
7
- _config_version: 2,
7
+ _config_version: 3,
8
8
  display: { skin: 'default', tool_progress_command: false, background_process_notifications: 'all' },
9
- agent: { provider: 'anthropic', model: '', max_iterations: 90, fallback_model: null, save_trajectories: false, model_preference: [] },
9
+ agent: { provider: 'anthropic', model: '', max_iterations: 90, fallback_model: null, save_trajectories: false, model_preference: [], model_queues: {}, discovered_models: {} },
10
10
  memory: { provider: null },
11
11
  skills: { config: {} },
12
12
  terminal: { cwd: null },
@@ -18,6 +18,7 @@ export const DEFAULT_CONFIG = {
18
18
  const MIGRATIONS = {
19
19
  1: cfg => cfg,
20
20
  2: cfg => { if (!cfg.agent) cfg.agent = {}; if (!Array.isArray(cfg.agent.model_preference)) cfg.agent.model_preference = []; return cfg },
21
+ 3: cfg => { if (!cfg.agent) cfg.agent = {}; if (!cfg.agent.model_queues || typeof cfg.agent.model_queues !== 'object') cfg.agent.model_queues = {}; if (!cfg.agent.discovered_models || typeof cfg.agent.discovered_models !== 'object') cfg.agent.discovered_models = {}; return cfg },
21
22
  }
22
23
 
23
24
  export function configPath() { return path.join(getFreddieHome(), 'config.yaml') }