blyuecode 0.2.0 → 0.3.0
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/dist/core/models.js +150 -0
- package/dist/ui/Chat.js +13 -5
- package/dist/ui/ModelPicker.js +38 -13
- package/package.json +1 -1
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Universal model fetcher — works with any OpenAI-compatible /v1/models
|
|
2
|
+
// endpoint: OpenAI, OpenRouter, Groq, xAI (Grok), Ollama, LM Studio,
|
|
3
|
+
// llama.cpp, vLLM, together, deepseek, etc.
|
|
4
|
+
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
5
|
+
let cache = null;
|
|
6
|
+
function currentBaseUrl() {
|
|
7
|
+
const raw = process.env.OPENAI_BASE_URL ||
|
|
8
|
+
process.env.OPENAI_API_BASE ||
|
|
9
|
+
'https://api.openai.com/v1';
|
|
10
|
+
// Strip trailing slash for consistent concat.
|
|
11
|
+
return raw.replace(/\/+$/, '');
|
|
12
|
+
}
|
|
13
|
+
function currentApiKey() {
|
|
14
|
+
return process.env.OPENAI_API_KEY || process.env.API_KEY || '';
|
|
15
|
+
}
|
|
16
|
+
function providerLabel(baseUrl) {
|
|
17
|
+
const u = baseUrl.toLowerCase();
|
|
18
|
+
if (u.includes('openrouter'))
|
|
19
|
+
return 'OpenRouter';
|
|
20
|
+
if (u.includes('api.x.ai'))
|
|
21
|
+
return 'xAI';
|
|
22
|
+
if (u.includes('groq.com'))
|
|
23
|
+
return 'Groq';
|
|
24
|
+
if (u.includes('api.openai.com'))
|
|
25
|
+
return 'OpenAI';
|
|
26
|
+
if (u.includes('api.deepseek.com'))
|
|
27
|
+
return 'DeepSeek';
|
|
28
|
+
if (u.includes('api.together'))
|
|
29
|
+
return 'Together';
|
|
30
|
+
if (u.includes('localhost') || u.includes('127.0.0.1'))
|
|
31
|
+
return 'local';
|
|
32
|
+
// Fall back to the hostname.
|
|
33
|
+
try {
|
|
34
|
+
return new URL(baseUrl).host;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return 'provider';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function currentProviderLabel() {
|
|
41
|
+
return providerLabel(currentBaseUrl());
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Non-chat models we should hide from the picker. Conservative — better
|
|
45
|
+
* to show a couple of extras than to hide something the user wants.
|
|
46
|
+
*/
|
|
47
|
+
function isChatModel(id) {
|
|
48
|
+
const l = id.toLowerCase();
|
|
49
|
+
const block = [
|
|
50
|
+
'embed',
|
|
51
|
+
'embedding',
|
|
52
|
+
'whisper',
|
|
53
|
+
'-tts',
|
|
54
|
+
'tts-',
|
|
55
|
+
'dall-e',
|
|
56
|
+
'dalle',
|
|
57
|
+
'moderation',
|
|
58
|
+
'realtime',
|
|
59
|
+
'transcribe',
|
|
60
|
+
'-audio',
|
|
61
|
+
'audio-',
|
|
62
|
+
'image-',
|
|
63
|
+
'-vision-preview',
|
|
64
|
+
'davinci',
|
|
65
|
+
'babbage',
|
|
66
|
+
'curie',
|
|
67
|
+
'ada-',
|
|
68
|
+
];
|
|
69
|
+
return !block.some((b) => l.includes(b));
|
|
70
|
+
}
|
|
71
|
+
function parseNumber(v) {
|
|
72
|
+
if (v === undefined)
|
|
73
|
+
return undefined;
|
|
74
|
+
const n = typeof v === 'string' ? parseFloat(v) : v;
|
|
75
|
+
return Number.isFinite(n) ? n : undefined;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Fetches the model list from the current provider. Cached for 5 minutes
|
|
79
|
+
* per base URL, so reopening /model is instant but "new models" surface
|
|
80
|
+
* within one cache window.
|
|
81
|
+
*/
|
|
82
|
+
export async function fetchModels(opts) {
|
|
83
|
+
const baseUrl = currentBaseUrl();
|
|
84
|
+
const url = baseUrl + '/models';
|
|
85
|
+
if (!opts?.force &&
|
|
86
|
+
cache &&
|
|
87
|
+
cache.url === url &&
|
|
88
|
+
Date.now() - cache.at < CACHE_TTL_MS) {
|
|
89
|
+
return cache.models;
|
|
90
|
+
}
|
|
91
|
+
const headers = { Accept: 'application/json' };
|
|
92
|
+
const key = currentApiKey();
|
|
93
|
+
if (key)
|
|
94
|
+
headers.Authorization = `Bearer ${key}`;
|
|
95
|
+
let res;
|
|
96
|
+
try {
|
|
97
|
+
res = await fetch(url, { headers });
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
throw new Error(`Could not reach ${url}: ${err.message}. ` +
|
|
101
|
+
`Is the provider reachable and the base URL correct?`);
|
|
102
|
+
}
|
|
103
|
+
if (!res.ok) {
|
|
104
|
+
throw new Error(`${url} returned HTTP ${res.status} ${res.statusText}. ` +
|
|
105
|
+
`Check your API key and base URL in /settings.`);
|
|
106
|
+
}
|
|
107
|
+
const body = (await res.json());
|
|
108
|
+
const raw = Array.isArray(body) ? body : body.data ?? [];
|
|
109
|
+
const models = raw
|
|
110
|
+
.map((m) => {
|
|
111
|
+
const id = m.id || m.name;
|
|
112
|
+
if (!id)
|
|
113
|
+
return null;
|
|
114
|
+
const prompt = parseNumber(m.pricing?.prompt);
|
|
115
|
+
const completion = parseNumber(m.pricing?.completion);
|
|
116
|
+
const isFree = prompt !== undefined &&
|
|
117
|
+
completion !== undefined &&
|
|
118
|
+
prompt === 0 &&
|
|
119
|
+
completion === 0;
|
|
120
|
+
return {
|
|
121
|
+
id,
|
|
122
|
+
contextLength: m.context_length ?? m.context_window,
|
|
123
|
+
ownedBy: m.owned_by,
|
|
124
|
+
pricing: prompt !== undefined && completion !== undefined
|
|
125
|
+
? { prompt, completion }
|
|
126
|
+
: undefined,
|
|
127
|
+
isFree,
|
|
128
|
+
};
|
|
129
|
+
})
|
|
130
|
+
.filter((m) => m !== null)
|
|
131
|
+
.filter((m) => isChatModel(m.id));
|
|
132
|
+
// Sort: free first, then alphabetical. (OpenRouter returns them in
|
|
133
|
+
// arbitrary order; this makes the useful ones surface at the top.)
|
|
134
|
+
models.sort((a, b) => {
|
|
135
|
+
if (!!a.isFree !== !!b.isFree)
|
|
136
|
+
return a.isFree ? -1 : 1;
|
|
137
|
+
return a.id.localeCompare(b.id);
|
|
138
|
+
});
|
|
139
|
+
cache = { at: Date.now(), url, models };
|
|
140
|
+
return models;
|
|
141
|
+
}
|
|
142
|
+
export function clearModelCache() {
|
|
143
|
+
cache = null;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Returns the current model ID — from config/env.
|
|
147
|
+
*/
|
|
148
|
+
export function currentModelId() {
|
|
149
|
+
return process.env.MODEL || '';
|
|
150
|
+
}
|
package/dist/ui/Chat.js
CHANGED
|
@@ -16,6 +16,7 @@ import { useSlowColor } from './useSlowColor.js';
|
|
|
16
16
|
import { pastelAt } from './pastel.js';
|
|
17
17
|
import { bumpActivity } from './activity.js';
|
|
18
18
|
import { theme } from './theme.js';
|
|
19
|
+
import { getConfig, saveConfig, applyConfig } from '../core/config.js';
|
|
19
20
|
let nextId = 0;
|
|
20
21
|
const mkItem = (kind, text) => ({ kind, text, id: nextId++ });
|
|
21
22
|
const HistoryLine = ({ item }) => {
|
|
@@ -100,9 +101,9 @@ export const Chat = ({ resumedCount = 0 }) => {
|
|
|
100
101
|
bumpActivity();
|
|
101
102
|
if (permReq)
|
|
102
103
|
return;
|
|
103
|
-
if (key.ctrl && inputChar === '
|
|
104
|
+
if (key.ctrl && inputChar === 'l')
|
|
104
105
|
setOverlay('model');
|
|
105
|
-
if (key.ctrl && inputChar === '
|
|
106
|
+
if (key.ctrl && inputChar === 'o')
|
|
106
107
|
setOverlay('settings');
|
|
107
108
|
});
|
|
108
109
|
const flush = (kind, text) => {
|
|
@@ -169,8 +170,8 @@ export const Chat = ({ resumedCount = 0 }) => {
|
|
|
169
170
|
setHistory((h) => [
|
|
170
171
|
...h,
|
|
171
172
|
mkItem('tool', 'Commands:'),
|
|
172
|
-
mkItem('tool', ' /model switch model (Ctrl+
|
|
173
|
-
mkItem('tool', ' /settings open settings (Ctrl+
|
|
173
|
+
mkItem('tool', ' /model switch model (Ctrl+L)'),
|
|
174
|
+
mkItem('tool', ' /settings open settings (Ctrl+O)'),
|
|
174
175
|
mkItem('tool', ' /compact summarize history to free context'),
|
|
175
176
|
mkItem('tool', ' /debug show last request to provider'),
|
|
176
177
|
mkItem('tool', ' /clear clear session and permissions'),
|
|
@@ -227,10 +228,17 @@ export const Chat = ({ resumedCount = 0 }) => {
|
|
|
227
228
|
};
|
|
228
229
|
const handleModelSelect = (id) => {
|
|
229
230
|
process.env.MODEL = id;
|
|
231
|
+
try {
|
|
232
|
+
const cfg = getConfig();
|
|
233
|
+
const next = { ...cfg, model: id };
|
|
234
|
+
saveConfig(next);
|
|
235
|
+
applyConfig(next);
|
|
236
|
+
}
|
|
237
|
+
catch { }
|
|
230
238
|
setCurrentModel(id);
|
|
231
239
|
setOverlay('none');
|
|
232
240
|
setAgent(new Agent());
|
|
233
|
-
setHistory((h) => [...h, mkItem('tool',
|
|
241
|
+
setHistory((h) => [...h, mkItem('tool', 'model → ' + id)]);
|
|
234
242
|
};
|
|
235
243
|
if (permReq) {
|
|
236
244
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: history, children: (item) => _jsx(HistoryLine, { item: item }, item.id) }), _jsx(Box, { marginTop: 1, children: _jsx(PermissionPrompt, { req: permReq }) }), _jsx(Box, { marginTop: 1, children: _jsx(InputBar, { value: "", onChange: () => { }, onSubmit: () => { }, loading: false, mascotState: "permission", awaitingPermission: true }) })] }));
|
package/dist/ui/ModelPicker.js
CHANGED
|
@@ -1,22 +1,39 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useState, useEffect } from 'react';
|
|
3
3
|
import { Box, Text, useInput, useStdout } from 'ink';
|
|
4
|
-
import {
|
|
4
|
+
import { fetchModels, currentProviderLabel, } from '../core/models.js';
|
|
5
5
|
import { theme } from './theme.js';
|
|
6
6
|
export const ModelPicker = ({ current, onSelect, onCancel }) => {
|
|
7
7
|
const { stdout } = useStdout();
|
|
8
8
|
const width = Math.max(40, stdout?.columns ?? 60);
|
|
9
|
+
const provider = currentProviderLabel();
|
|
9
10
|
const [models, setModels] = useState([]);
|
|
10
11
|
const [loading, setLoading] = useState(true);
|
|
12
|
+
const [error, setError] = useState(null);
|
|
11
13
|
const [idx, setIdx] = useState(0);
|
|
12
14
|
const [filter, setFilter] = useState('');
|
|
13
15
|
useEffect(() => {
|
|
14
|
-
|
|
16
|
+
let cancelled = false;
|
|
17
|
+
setLoading(true);
|
|
18
|
+
setError(null);
|
|
19
|
+
fetchModels()
|
|
20
|
+
.then((m) => {
|
|
21
|
+
if (cancelled)
|
|
22
|
+
return;
|
|
15
23
|
setModels(m);
|
|
16
24
|
const found = m.findIndex((x) => x.id === current);
|
|
17
25
|
setIdx(found >= 0 ? found : 0);
|
|
18
26
|
setLoading(false);
|
|
27
|
+
})
|
|
28
|
+
.catch((err) => {
|
|
29
|
+
if (cancelled)
|
|
30
|
+
return;
|
|
31
|
+
setError(err.message);
|
|
32
|
+
setLoading(false);
|
|
19
33
|
});
|
|
34
|
+
return () => {
|
|
35
|
+
cancelled = true;
|
|
36
|
+
};
|
|
20
37
|
}, [current]);
|
|
21
38
|
const filtered = filter
|
|
22
39
|
? models.filter((m) => m.id.toLowerCase().includes(filter.toLowerCase()))
|
|
@@ -24,9 +41,9 @@ export const ModelPicker = ({ current, onSelect, onCancel }) => {
|
|
|
24
41
|
useInput((input, key) => {
|
|
25
42
|
if (key.escape)
|
|
26
43
|
return onCancel();
|
|
27
|
-
if (key.upArrow)
|
|
44
|
+
if (key.upArrow && filtered.length)
|
|
28
45
|
setIdx((i) => (i - 1 + filtered.length) % filtered.length);
|
|
29
|
-
if (key.downArrow)
|
|
46
|
+
if (key.downArrow && filtered.length)
|
|
30
47
|
setIdx((i) => (i + 1) % filtered.length);
|
|
31
48
|
if (key.return && filtered[idx])
|
|
32
49
|
onSelect(filtered[idx].id);
|
|
@@ -40,18 +57,26 @@ export const ModelPicker = ({ current, onSelect, onCancel }) => {
|
|
|
40
57
|
});
|
|
41
58
|
const inner = width - 4;
|
|
42
59
|
const maxVisible = 12;
|
|
43
|
-
const start = Math.max(0, Math.min(idx - 6, filtered.length - maxVisible));
|
|
60
|
+
const start = Math.max(0, Math.min(idx - 6, Math.max(0, filtered.length - maxVisible)));
|
|
44
61
|
const visible = filtered.slice(start, start + maxVisible);
|
|
45
|
-
const
|
|
46
|
-
const topPad = Math.max(1, inner -
|
|
47
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '╭─ ' }), _jsx(Text, { color: theme.accent, bold: true, children:
|
|
62
|
+
const label = filter ? `filter: ${filter}` : `${provider} models`;
|
|
63
|
+
const topPad = Math.max(1, inner - label.length - 4);
|
|
64
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '╭─ ' }), _jsx(Text, { color: theme.accent, bold: true, children: label }), _jsx(Text, { color: theme.muted, children: ' ' + '─'.repeat(topPad) + '╮' })] }), loading ? (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsxs(Text, { color: theme.muted, children: ["fetching ", provider, " catalog\u2026"] })] })) : error ? (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsxs(Text, { color: theme.error, children: ["\u2717 ", error.slice(0, inner - 6)] })] })) : visible.length === 0 ? (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: theme.error, children: "no matches" })] })) : (visible.map((m, i) => {
|
|
48
65
|
const realIdx = start + i;
|
|
49
66
|
const active = realIdx === idx;
|
|
50
67
|
const isCurrent = m.id === current;
|
|
51
|
-
const ctx = m.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
68
|
+
const ctx = m.contextLength
|
|
69
|
+
? m.contextLength >= 1000
|
|
70
|
+
? `${Math.round(m.contextLength / 1000)}k`
|
|
71
|
+
: `${m.contextLength}`
|
|
72
|
+
: '';
|
|
73
|
+
const freeTag = m.isFree ? 'FREE' : '';
|
|
74
|
+
// Right side: ctx + free badge
|
|
75
|
+
const right = [ctx, freeTag].filter(Boolean).join(' · ');
|
|
76
|
+
const label0 = m.id.length > inner - 10
|
|
77
|
+
? m.id.slice(0, inner - 13) + '…'
|
|
78
|
+
: m.id;
|
|
79
|
+
const pad = Math.max(1, inner - label0.length - right.length - 4);
|
|
80
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: active ? theme.accent : theme.muted, bold: active, children: active ? '❯ ' : ' ' }), _jsx(Text, { color: active ? theme.text : theme.muted, bold: active, children: label0 }), _jsx(Text, { color: theme.muted, children: ' '.repeat(pad) }), _jsx(Text, { color: theme.muted, children: right }), isCurrent && _jsx(Text, { color: theme.accent, children: " \u2713" })] }, m.id));
|
|
56
81
|
})), _jsx(Text, { children: _jsx(Text, { color: theme.muted, children: '╰' + '─'.repeat(inner) + '╯' }) }), _jsx(Text, { color: theme.muted, children: "\u2191\u2193 move \u00B7 type to filter \u00B7 \u21B5 select \u00B7 esc cancel" })] }));
|
|
57
82
|
};
|