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.
@@ -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 === 'p')
104
+ if (key.ctrl && inputChar === 'l')
104
105
  setOverlay('model');
105
- if (key.ctrl && inputChar === 's')
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+P)'),
173
- mkItem('tool', ' /settings open settings (Ctrl+S)'),
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', `model → ${id}`)]);
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 }) })] }));
@@ -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 { fetchFreeModels } from '../core/freeModels.js';
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
- fetchFreeModels(process.env.OPENAI_API_KEY).then((m) => {
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 topLabel = filter ? `filter: ${filter}` : 'free models';
46
- const topPad = Math.max(1, inner - topLabel.length - 4);
47
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '╭─ ' }), _jsx(Text, { color: theme.accent, bold: true, children: topLabel }), _jsx(Text, { color: theme.muted, children: ' ' + '─'.repeat(topPad) + '╮' })] }), loading ? (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: theme.muted, children: "loading catalog\u2026" })] })) : visible.length === 0 ? (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: theme.error, children: "no matches" })] })) : (visible.map((m, i) => {
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.context >= 1000 ? `${Math.round(m.context / 1000)}k` : `${m.context}`;
52
- const label = m.id.length > inner - 10 ? m.id.slice(0, inner - 13) + '…' : m.id;
53
- const right = `${ctx}`;
54
- const pad = Math.max(1, inner - label.length - right.length - 4);
55
- 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: label }), _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));
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blyuecode",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "BLYUE Code — a coding agent by Lumen",
5
5
  "type": "module",
6
6
  "license": "MIT",