@orbit-intelligence/orbit-agent 0.3.14 → 0.3.15
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/src/cli/run.js +2 -2
- package/dist/src/core/agent/agent-loop.js +9 -4
- package/dist/src/core/llm/endpoints.js +110 -0
- package/dist/src/core/llm/index.js +16 -0
- package/dist/src/core/llm/sanitize.js +47 -0
- package/dist/src/core/llm/secrets.js +61 -3
- package/dist/src/tui/InkApp.js +5 -1
- package/dist/src/tui/app.js +269 -15
- package/dist/src/tui/components/ModelPicker.js +13 -5
- package/dist/src/tui/picker.js +127 -39
- package/dist/src/tui/store.js +6 -0
- package/dist/src/utils/platform.js +3 -0
- package/package.json +1 -1
package/dist/src/cli/run.js
CHANGED
|
@@ -5,7 +5,7 @@ import { VERSION } from '../version.js';
|
|
|
5
5
|
import { touchConfig, saveConfig } from '../config/index.js';
|
|
6
6
|
import { EventBus } from '../core/events.js';
|
|
7
7
|
import { buildProviders, resolveCandidates } from '../core/llm/index.js';
|
|
8
|
-
import {
|
|
8
|
+
import { resolveModelRows, resolveProviderRows } from '../tui/picker.js';
|
|
9
9
|
import { AutoRouter } from '../core/llm/router.js';
|
|
10
10
|
import { ContextManager } from '../core/context/context-manager.js';
|
|
11
11
|
import { ToolRegistry } from '../core/tools/registry.js';
|
|
@@ -248,7 +248,7 @@ export async function main(argv) {
|
|
|
248
248
|
onCommand,
|
|
249
249
|
version: VERSION,
|
|
250
250
|
models: router.order(),
|
|
251
|
-
pickRows: () =>
|
|
251
|
+
pickRows: (stage, providerId, liveModels) => stage === 'providers' ? Promise.resolve(resolveProviderRows()) : resolveModelRows(providerId ?? '', liveModels),
|
|
252
252
|
});
|
|
253
253
|
app.store.skills = project.skills.map((s) => ({ name: s.name, summary: s.summary }));
|
|
254
254
|
const resumeMessages = session.messages.filter((m) => m.role !== 'tool');
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sanitizeToken, sanitizeContent } from '../llm/sanitize.js';
|
|
1
2
|
import { combineSignals } from '../../utils/signals.js';
|
|
2
3
|
import { unifiedDiff, countChanges } from '../../utils/diff.js';
|
|
3
4
|
import { readFile, stat } from 'node:fs/promises';
|
|
@@ -87,13 +88,15 @@ export class AgentLoop {
|
|
|
87
88
|
try {
|
|
88
89
|
for await (const ev of gen) {
|
|
89
90
|
if (ev.type === 'token') {
|
|
90
|
-
|
|
91
|
-
|
|
91
|
+
const clean = sanitizeToken(ev.text);
|
|
92
|
+
asstMsg.content += clean;
|
|
93
|
+
bus.emit('onToken', clean);
|
|
92
94
|
await throttle(this.opts.maxTokensPerSecond);
|
|
93
95
|
}
|
|
94
96
|
else if (ev.type === 'reasoning') {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
+
const clean = sanitizeToken(ev.text);
|
|
98
|
+
asstMsg.reasoning = (asstMsg.reasoning ?? '') + clean;
|
|
99
|
+
bus.emit('onThinking', clean);
|
|
97
100
|
}
|
|
98
101
|
else if (ev.type === 'tool_call_start') {
|
|
99
102
|
const call = {
|
|
@@ -153,6 +156,8 @@ export class AgentLoop {
|
|
|
153
156
|
// TUI reveal the whole message (snap the typewriter to the end) so the
|
|
154
157
|
// text sits above the tool rows BEFORE the tools start running — the
|
|
155
158
|
// user should see "intent first, then action".
|
|
159
|
+
asstMsg.content = sanitizeContent(asstMsg.content);
|
|
160
|
+
asstMsg.reasoning = sanitizeContent(asstMsg.reasoning ?? '');
|
|
156
161
|
bus.emit('onAssistantGenerationDone', asstMsg);
|
|
157
162
|
asstMsg.streaming = false;
|
|
158
163
|
asstMsg.reasoningOpen = (asstMsg.reasoning?.length ?? 0) > 0;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom OpenAI-compatible endpoint registry.
|
|
3
|
+
*
|
|
4
|
+
* Lets users add named endpoints (LM Studio, vLLM, a personal gateway, …)
|
|
5
|
+
* with a base URL and an OPTIONAL key. Persisted to the git-ignored
|
|
6
|
+
* `endpoints.json` (0600) next to keys.json. Base URLs are not secrets, but
|
|
7
|
+
* endpoint keys are — keeping both in one 0600 file matches the keys.json
|
|
8
|
+
* security posture (never config.json).
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync, existsSync, chmodSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { endpointsPath, configDir } from '../../utils/platform.js';
|
|
12
|
+
const CACHE_MS = 2000;
|
|
13
|
+
let cache = null;
|
|
14
|
+
function readFile() {
|
|
15
|
+
const now = Date.now();
|
|
16
|
+
if (cache && now - cache.at < CACHE_MS)
|
|
17
|
+
return cache.data;
|
|
18
|
+
const path = endpointsPath();
|
|
19
|
+
if (!existsSync(path)) {
|
|
20
|
+
cache = { data: [], at: now };
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
25
|
+
if (!Array.isArray(raw))
|
|
26
|
+
throw new Error('not an array');
|
|
27
|
+
const cleaned = raw.filter((e) => e && typeof e.name === 'string' && typeof e.baseUrl === 'string');
|
|
28
|
+
cache = { data: cleaned, at: now };
|
|
29
|
+
return cleaned;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
cache = { data: [], at: now };
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function write(list) {
|
|
37
|
+
mkdirSync(configDir(), { recursive: true });
|
|
38
|
+
writeFileSync(endpointsPath(), JSON.stringify(list, null, 2), 'utf8');
|
|
39
|
+
try {
|
|
40
|
+
chmodSync(endpointsPath(), 0o600);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
/* best-effort */
|
|
44
|
+
}
|
|
45
|
+
cache = { data: list, at: Date.now() };
|
|
46
|
+
}
|
|
47
|
+
export function loadEndpoints() {
|
|
48
|
+
return readFile();
|
|
49
|
+
}
|
|
50
|
+
export function findEndpoint(name) {
|
|
51
|
+
return readFile().find((e) => e.name === name) ?? null;
|
|
52
|
+
}
|
|
53
|
+
/** Upsert by name (case-sensitive). Returns an error string, or null on success. */
|
|
54
|
+
export function saveEndpoint(ep) {
|
|
55
|
+
const trimmed = { ...ep, name: ep.name.trim(), baseUrl: ep.baseUrl.trim() };
|
|
56
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,39}$/.test(trimmed.name)) {
|
|
57
|
+
return 'name must be 1-40 chars: lowercase letters, digits, . _ -';
|
|
58
|
+
}
|
|
59
|
+
if (!/^https?:\/\/\S+$/.test(trimmed.baseUrl)) {
|
|
60
|
+
return 'base URL must start with http:// or https://';
|
|
61
|
+
}
|
|
62
|
+
if (!trimmed.baseUrl.endsWith('/v1') && !/\/(v1|v1beta1)\/?$/.test(trimmed.baseUrl)) {
|
|
63
|
+
trimmed.baseUrl = `${trimmed.baseUrl.replace(/\/+$/, '')}/v1`;
|
|
64
|
+
}
|
|
65
|
+
const list = readFile();
|
|
66
|
+
const idx = list.findIndex((e) => e.name === trimmed.name);
|
|
67
|
+
if (idx >= 0)
|
|
68
|
+
list[idx] = trimmed;
|
|
69
|
+
else
|
|
70
|
+
list.push(trimmed);
|
|
71
|
+
write(list);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
export function deleteEndpoint(name) {
|
|
75
|
+
write(readFile().filter((e) => e.name !== name));
|
|
76
|
+
}
|
|
77
|
+
/** Remember a model id the user actually picked for this endpoint. */
|
|
78
|
+
export function rememberEndpointModel(name, model) {
|
|
79
|
+
const list = readFile();
|
|
80
|
+
const ep = list.find((e) => e.name === name);
|
|
81
|
+
if (!ep)
|
|
82
|
+
return;
|
|
83
|
+
const models = ep.models ?? [];
|
|
84
|
+
if (!models.includes(model)) {
|
|
85
|
+
ep.models = [...models, model].slice(-20);
|
|
86
|
+
write(list);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Probe the endpoint's live /v1/models list (fast-fail, short timeout). */
|
|
90
|
+
export async function listEndpointModels(name) {
|
|
91
|
+
const ep = findEndpoint(name);
|
|
92
|
+
if (!ep)
|
|
93
|
+
return [];
|
|
94
|
+
try {
|
|
95
|
+
const ctrl = new AbortController();
|
|
96
|
+
const timer = setTimeout(() => ctrl.abort(new Error('endpoint probe timed out')), 3000);
|
|
97
|
+
const headers = { accept: 'application/json' };
|
|
98
|
+
if (ep.key)
|
|
99
|
+
headers.authorization = `Bearer ${ep.key}`;
|
|
100
|
+
const res = await fetch(`${ep.baseUrl.replace(/\/+$/, '')}/models`, { signal: ctrl.signal, headers });
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
if (!res.ok)
|
|
103
|
+
throw new Error(`HTTP ${res.status}`);
|
|
104
|
+
const json = (await res.json());
|
|
105
|
+
return (json.data ?? []).map((m) => m.id).filter(Boolean);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -3,6 +3,7 @@ import { createOpenAiProvider } from './providers/openai-compat.js';
|
|
|
3
3
|
import { createGeminiProvider } from './providers/gemini.js';
|
|
4
4
|
import { createAnthropicProvider } from './providers/anthropic.js';
|
|
5
5
|
import { createOllamaProvider } from './providers/ollama.js';
|
|
6
|
+
import { loadEndpoints } from './endpoints.js';
|
|
6
7
|
import { PROVIDER_CATALOGS, ORBITX_SERVE, modelsOf } from './models.js';
|
|
7
8
|
export { PROVIDER_CATALOGS as PROVIDER_SPECS, ORBITX_SERVE, modelsOf };
|
|
8
9
|
const ENDPOINTS = {
|
|
@@ -65,6 +66,21 @@ export function buildProviders(config) {
|
|
|
65
66
|
}
|
|
66
67
|
// Local Ollama is always offered: no key required, auto-detects the server.
|
|
67
68
|
providers.ollama = createOllamaProvider();
|
|
69
|
+
// Named custom OpenAI-compatible endpoints (endpoints.json). Each is exposed
|
|
70
|
+
// as a provider keyed by its name so `<name>/<model>` routes to it.
|
|
71
|
+
for (const ep of loadEndpoints()) {
|
|
72
|
+
if (!ep || !ep.name || !ep.baseUrl)
|
|
73
|
+
continue;
|
|
74
|
+
const existing = providers[ep.name];
|
|
75
|
+
if (existing)
|
|
76
|
+
continue; // never shadow a real provider with a same-named endpoint
|
|
77
|
+
providers[ep.name] = createOpenAiProvider({
|
|
78
|
+
id: ep.name,
|
|
79
|
+
baseUrl: ep.baseUrl,
|
|
80
|
+
apiKey: ep.key ?? '',
|
|
81
|
+
models: (ep.models ?? []).filter(Boolean),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
68
84
|
return providers;
|
|
69
85
|
}
|
|
70
86
|
/** Candidate model ids (provider-prefixed) for the router. */
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streamed-content sanitizer.
|
|
3
|
+
*
|
|
4
|
+
* Some gateway models (gpt-oss family, Qwen cloud variants) leak control
|
|
5
|
+
* tokens into the visible content stream: `<|start|>functions.list_dir
|
|
6
|
+
* to=assistant<|message|>{...}` pensieve blocks, dangling `<|...|>` tags,
|
|
7
|
+
* and boundary boilerplate such as a lone `response` line or "Proceed to
|
|
8
|
+
* final answer." Strip those before the TUI renders or history commits them
|
|
9
|
+
* so the user sees the prose, not the plumbing.
|
|
10
|
+
*/
|
|
11
|
+
/** Control-rune tag, e.g. `<|start|>`, `<|message|>`, `<|end|>`. */
|
|
12
|
+
const TAG_RE = /<\|[^|>]*\|>/g;
|
|
13
|
+
/**
|
|
14
|
+
* Qwen/OSS pensieve header: `<|start|>functions.<name> to=assistant
|
|
15
|
+
* <|message|>` followed by a mangled JSON echo that model emits verbatim.
|
|
16
|
+
* Removes the header plus the leading portion of the echoed line up to the
|
|
17
|
+
* first blank line, which is where the real prose begins in practice.
|
|
18
|
+
*/
|
|
19
|
+
const PENSIVE_RE = /<\|start\|>\n?[\s\S]*?<\|message\|>[ \t]*[^\r\n]*\n/g;
|
|
20
|
+
/** Lone boundary markers some cloud models insert before their answer. */
|
|
21
|
+
const BOUNDARY_LINE_RE = /^[ \t]*(?:response|Proceed to final answer\.?)[ \t]*$/gm;
|
|
22
|
+
/** Collapse 3+ consecutive blank lines down to two (post-strip tidy-up). */
|
|
23
|
+
const BLANK_RUN_RE = /\n{3,}/g;
|
|
24
|
+
/**
|
|
25
|
+
* Sanitize a single streamed token. idempotent; cheap enough to run per
|
|
26
|
+
* token. A `<|` opened mid-chunk but closed later is finished by
|
|
27
|
+
* `sanitizeContent` over the assembled text, so partial matches here are
|
|
28
|
+
* fine.
|
|
29
|
+
*/
|
|
30
|
+
export function sanitizeToken(text) {
|
|
31
|
+
let s = text.replace(PENSIVE_RE, '').replace(TAG_RE, '');
|
|
32
|
+
if (s.includes('response') || s.includes('Proceed to final answer')) {
|
|
33
|
+
s = s.replace(BOUNDARY_LINE_RE, '');
|
|
34
|
+
}
|
|
35
|
+
return s;
|
|
36
|
+
}
|
|
37
|
+
/** Sanitize a fully-assembled message/reasoning blob. */
|
|
38
|
+
export function sanitizeContent(text) {
|
|
39
|
+
let s = text
|
|
40
|
+
.replace(PENSIVE_RE, '')
|
|
41
|
+
.replace(TAG_RE, '')
|
|
42
|
+
.replace(BOUNDARY_LINE_RE, '')
|
|
43
|
+
.replace(BLANK_RUN_RE, '\n\n')
|
|
44
|
+
.replace(/^\n+/, '')
|
|
45
|
+
.replace(/\n+$/, '');
|
|
46
|
+
return s;
|
|
47
|
+
}
|
|
@@ -83,8 +83,9 @@ export function getProviderSecrets(provider) {
|
|
|
83
83
|
return { provider, keys: envKeys, source: 'env' };
|
|
84
84
|
const file = readKeysFile();
|
|
85
85
|
const fileKeys = file[provider] ?? [];
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
const flat = fileKeys.map((k) => (typeof k === 'string' ? k : k.key)).filter(Boolean);
|
|
87
|
+
if (flat.length > 0)
|
|
88
|
+
return { provider, keys: flat, source: 'file' };
|
|
88
89
|
return null;
|
|
89
90
|
}
|
|
90
91
|
export function hasAnySecrets() {
|
|
@@ -108,12 +109,30 @@ export function describeProvidersAvailable() {
|
|
|
108
109
|
}
|
|
109
110
|
return out;
|
|
110
111
|
}
|
|
112
|
+
export function describeProviderKey(provider) {
|
|
113
|
+
const s = getProviderSecrets(provider);
|
|
114
|
+
if (!s || !s.keys[0])
|
|
115
|
+
return { provider, available: false };
|
|
116
|
+
const named = readKeysFile()[provider]?.find((k) => typeof k === 'object');
|
|
117
|
+
return {
|
|
118
|
+
provider,
|
|
119
|
+
available: true,
|
|
120
|
+
masked: maskSecret(s.keys[0]),
|
|
121
|
+
source: s.source,
|
|
122
|
+
label: named?.name,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
111
125
|
/**
|
|
112
126
|
* Persist provider keys to the git-ignored keys.json (0600), merging over any
|
|
113
127
|
* existing keys so a re-run of the wizard never wipes other providers.
|
|
114
128
|
*/
|
|
115
129
|
export function writeKeys(provider, keys) {
|
|
116
|
-
const trimmed = keys
|
|
130
|
+
const trimmed = keys
|
|
131
|
+
.map((k) => {
|
|
132
|
+
const key = typeof k === 'string' ? k.trim() : { name: k.name.trim() || 'default', key: k.key.trim() };
|
|
133
|
+
return typeof key === 'string' ? key : { name: key.name, key: key.key };
|
|
134
|
+
})
|
|
135
|
+
.filter((k) => (typeof k === 'string' ? k : k.key) !== '');
|
|
117
136
|
if (trimmed.length === 0)
|
|
118
137
|
return 'no keys provided';
|
|
119
138
|
const existing = readKeysFile();
|
|
@@ -129,3 +148,42 @@ export function writeKeys(provider, keys) {
|
|
|
129
148
|
return err.message;
|
|
130
149
|
}
|
|
131
150
|
}
|
|
151
|
+
/** Save (or update by label) a single named key for a provider. */
|
|
152
|
+
export function saveNamedKey(provider, name, key) {
|
|
153
|
+
const clean = key.trim();
|
|
154
|
+
if (!clean)
|
|
155
|
+
return 'no key provided';
|
|
156
|
+
const label = name.trim() || 'default';
|
|
157
|
+
const existing = readKeysFile();
|
|
158
|
+
const list = existing[provider] ?? [];
|
|
159
|
+
const idx = list.findIndex((k) => typeof k === 'object' && k.name === label);
|
|
160
|
+
if (idx >= 0)
|
|
161
|
+
list[idx] = { name: label, key: clean };
|
|
162
|
+
else
|
|
163
|
+
list.push({ name: label, key: clean });
|
|
164
|
+
existing[provider] = list;
|
|
165
|
+
try {
|
|
166
|
+
mkdirSync(configDir(), { recursive: true });
|
|
167
|
+
writeFileSync(keysPath(), JSON.stringify(existing, null, 2), 'utf8');
|
|
168
|
+
chmodSync(keysPath(), 0o600);
|
|
169
|
+
invalidateKeysCache();
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
return err.message;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/** Human "masked · source [label]" description for a provider's configured key. */
|
|
177
|
+
export function describeKey(provider) {
|
|
178
|
+
const envKeys = fromEnv(provider);
|
|
179
|
+
if (envKeys.length > 0 && envKeys[0])
|
|
180
|
+
return { masked: maskSecret(envKeys[0]), source: 'env' };
|
|
181
|
+
const file = readKeysFile();
|
|
182
|
+
const entries = file[provider] ?? [];
|
|
183
|
+
const first = entries[0];
|
|
184
|
+
if (!first)
|
|
185
|
+
return null;
|
|
186
|
+
if (typeof first === 'string')
|
|
187
|
+
return { masked: maskSecret(first), source: 'file' };
|
|
188
|
+
return { masked: maskSecret(first.key), source: 'file', label: first.name };
|
|
189
|
+
}
|
package/dist/src/tui/InkApp.js
CHANGED
|
@@ -33,7 +33,11 @@ export function InkApp({ controller }) {
|
|
|
33
33
|
const theme = store.theme ?? buildTheme('tokyonight');
|
|
34
34
|
const menuing = store.input.currentBuffer().startsWith('/');
|
|
35
35
|
const menuLines = menuing ? store.slashMatches().length + 1 : 0;
|
|
36
|
-
const modelLines = store.modelPicker.open
|
|
36
|
+
const modelLines = store.modelPicker.open
|
|
37
|
+
? store.keyEntry
|
|
38
|
+
? 8
|
|
39
|
+
: Math.min(store.pickRows.length, 12) + 3
|
|
40
|
+
: 0;
|
|
37
41
|
const dockLines = store.dockOpen
|
|
38
42
|
? store.agents.size === 0
|
|
39
43
|
? 2
|
package/dist/src/tui/app.js
CHANGED
|
@@ -4,7 +4,10 @@ import { providerNames } from '../config/config-schema.js';
|
|
|
4
4
|
import { makeTheme, THEME_NAMES } from './themes/index.js';
|
|
5
5
|
import { AppStore, ASK_OPTIONS } from './store.js';
|
|
6
6
|
import { InkApp } from './InkApp.js';
|
|
7
|
-
import { findPickerIndex, isPickerHeader } from './picker.js';
|
|
7
|
+
import { findPickerIndex, isPickerHeader, resolveModelRows, resolveProviderRows, } from './picker.js';
|
|
8
|
+
import { saveNamedKey } from '../core/llm/secrets.js';
|
|
9
|
+
import { saveEndpoint, rememberEndpointModel } from '../core/llm/endpoints.js';
|
|
10
|
+
import { isImportableDirectly } from '../core/llm/secrets.js';
|
|
8
11
|
export class TuiApp {
|
|
9
12
|
store = new AppStore();
|
|
10
13
|
theme;
|
|
@@ -87,28 +90,48 @@ export class TuiApp {
|
|
|
87
90
|
this.models = [...list];
|
|
88
91
|
this.store.models = [...list];
|
|
89
92
|
}
|
|
90
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* Rebuild the current picker stage's rows (providers, or models for the
|
|
95
|
+
* selected provider). The orbitx model list uses the LIVE routed models so
|
|
96
|
+
* offered == routable — this is the fix for "No model matches".
|
|
97
|
+
*/
|
|
91
98
|
async refreshModelPicker() {
|
|
92
|
-
|
|
93
|
-
|
|
99
|
+
const store = this.store;
|
|
100
|
+
const stage = store.pickStage;
|
|
101
|
+
const provider = store.pickProviderProviderId;
|
|
94
102
|
try {
|
|
95
|
-
const rows = await this.pickBuilder
|
|
96
|
-
|
|
103
|
+
const rows = await (this.pickBuilder
|
|
104
|
+
? this.pickBuilder(stage, provider, this.models)
|
|
105
|
+
: this.defaultRows(stage, provider));
|
|
106
|
+
store.pickRows = rows;
|
|
97
107
|
if (rows.length === 0)
|
|
98
|
-
|
|
99
|
-
else if (
|
|
100
|
-
|
|
108
|
+
store.modelPicker.index = 0;
|
|
109
|
+
else if (store.modelPicker.index >= rows.length)
|
|
110
|
+
store.modelPicker.index = rows.length - 1;
|
|
111
|
+
if (stage === 'providers') {
|
|
112
|
+
const cur = store.route?.provider;
|
|
113
|
+
const firstItem = rows.findIndex((r) => r.kind === 'item' && r.providerId === cur);
|
|
114
|
+
store.modelPicker.index = firstItem >= 0 ? firstItem : rows.findIndex((r) => r.kind === 'item');
|
|
115
|
+
}
|
|
101
116
|
}
|
|
102
117
|
catch {
|
|
103
|
-
|
|
118
|
+
store.pickRows = [];
|
|
104
119
|
}
|
|
105
120
|
}
|
|
106
|
-
|
|
121
|
+
defaultRows(stage, provider) {
|
|
122
|
+
if (stage === 'providers')
|
|
123
|
+
return Promise.resolve(resolveProviderRows());
|
|
124
|
+
return resolveModelRows(provider ?? '', this.models);
|
|
125
|
+
}
|
|
126
|
+
/** Open the /model overlay, starting at the provider (stage 1) list. */
|
|
107
127
|
async openModelPicker() {
|
|
108
|
-
await this.refreshModelPicker();
|
|
109
128
|
const store = this.store;
|
|
110
|
-
|
|
111
|
-
|
|
129
|
+
store.pickStage = 'providers';
|
|
130
|
+
store.pickProviderProviderId = null;
|
|
131
|
+
store.keyEntry = null;
|
|
132
|
+
await this.refreshModelPicker();
|
|
133
|
+
const rows = store.pickRows;
|
|
134
|
+
if (rows.length === 0) {
|
|
112
135
|
const seen = new Set();
|
|
113
136
|
for (const m of store.models) {
|
|
114
137
|
const provider = m.split('/')[0] ?? 'orbitx';
|
|
@@ -118,7 +141,6 @@ export class TuiApp {
|
|
|
118
141
|
store.pickRows.push({ kind: 'item', label: m, id: m, provider });
|
|
119
142
|
}
|
|
120
143
|
}
|
|
121
|
-
store.modelPicker.index = findPickerIndex(store.pickRows, store.route?.model, this.config.model.primary);
|
|
122
144
|
store.modelPicker.open = true;
|
|
123
145
|
store.refresh();
|
|
124
146
|
}
|
|
@@ -519,6 +541,11 @@ export class TuiApp {
|
|
|
519
541
|
}
|
|
520
542
|
handleModelPickerKey(input, key) {
|
|
521
543
|
const store = this.store;
|
|
544
|
+
// Inline key/custom-endpoint entry box: capture text, Enter advances.
|
|
545
|
+
if (store.keyEntry) {
|
|
546
|
+
this.handleKeyEntryKey(input, key);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
522
549
|
const rows = store.pickRows;
|
|
523
550
|
if (rows.length === 0) {
|
|
524
551
|
store.modelPicker.open = false;
|
|
@@ -526,6 +553,13 @@ export class TuiApp {
|
|
|
526
553
|
return;
|
|
527
554
|
}
|
|
528
555
|
if (key.escape) {
|
|
556
|
+
if (store.pickStage === 'models') {
|
|
557
|
+
// Back to provider list (stage 1), re-enter the source-of-truth rows.
|
|
558
|
+
store.pickStage = 'providers';
|
|
559
|
+
store.pickProviderProviderId = null;
|
|
560
|
+
void this.refreshModelPicker().then(() => store.refresh());
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
529
563
|
store.modelPicker.open = false;
|
|
530
564
|
store.refresh();
|
|
531
565
|
return;
|
|
@@ -547,6 +581,10 @@ export class TuiApp {
|
|
|
547
581
|
store.refresh();
|
|
548
582
|
return;
|
|
549
583
|
}
|
|
584
|
+
if (store.pickStage === 'providers') {
|
|
585
|
+
void this.enterProviderScope(row);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
550
588
|
store.modelPicker.open = false;
|
|
551
589
|
store.refresh();
|
|
552
590
|
void this.selectPickerModel(row);
|
|
@@ -555,6 +593,215 @@ export class TuiApp {
|
|
|
555
593
|
if (input && !key.ctrl && !key.meta)
|
|
556
594
|
store.refresh();
|
|
557
595
|
}
|
|
596
|
+
/**
|
|
597
|
+
* Stage 1 Enter: open the chosen provider's key-entry (if missing a required
|
|
598
|
+
* key) or advance to its model list (stage 2).
|
|
599
|
+
*/
|
|
600
|
+
async enterProviderScope(row) {
|
|
601
|
+
const store = this.store;
|
|
602
|
+
const providerRow = row;
|
|
603
|
+
const providerId = providerRow.providerId;
|
|
604
|
+
// Add-a-custom-endpoint flow.
|
|
605
|
+
if (providerId === '__add_custom__') {
|
|
606
|
+
store.keyEntry = { kind: 'endpoint-name', provider: '', value: '', cursorPos: 0, hint: 'name for this endpoint (e.g. lmstudio)' };
|
|
607
|
+
store.refresh();
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
// Named key flows: provider needs a key and none is configured.
|
|
611
|
+
if (providerRow.needsKey) {
|
|
612
|
+
const p = providerId;
|
|
613
|
+
if (!isImportableDirectly(p)) {
|
|
614
|
+
this.note(`Provider "${providerId}" needs a key. Run \`orbit setup\` or set the env var.`);
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
store.keyEntry = { kind: 'key-label', provider: p, value: '', cursorPos: 0, hint: `label for this ${p} key (e.g. "work")` };
|
|
618
|
+
store.refresh();
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
await this.enterModelStage(providerId);
|
|
622
|
+
}
|
|
623
|
+
/** Advance to stage 2 (model list) for a provider, rebuilding rows live. */
|
|
624
|
+
async enterModelStage(providerId) {
|
|
625
|
+
const store = this.store;
|
|
626
|
+
store.pickStage = 'models';
|
|
627
|
+
store.pickProviderProviderId = providerId;
|
|
628
|
+
const rows = await (this.pickBuilder
|
|
629
|
+
? this.pickBuilder('models', providerId, this.models)
|
|
630
|
+
: resolveModelRows(providerId, this.models));
|
|
631
|
+
// Custom endpoint with no discoverable models: let the user type the id.
|
|
632
|
+
if (providerId.startsWith('custom:') &&
|
|
633
|
+
rows.length === 1 &&
|
|
634
|
+
rows[0]?.kind === 'header' &&
|
|
635
|
+
/no models found/.test(rows[0].label)) {
|
|
636
|
+
const name = providerId.slice('custom:'.length);
|
|
637
|
+
store.keyEntry = {
|
|
638
|
+
kind: 'endpoint-fallback-model',
|
|
639
|
+
provider: name,
|
|
640
|
+
value: '',
|
|
641
|
+
cursorPos: 0,
|
|
642
|
+
hint: `model id served by ${name} (no live /v1/models response)`,
|
|
643
|
+
};
|
|
644
|
+
store.pickRows = rows;
|
|
645
|
+
store.refresh();
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
store.pickRows = rows;
|
|
649
|
+
store.modelPicker.index = findPickerIndex(rows, store.route?.model, this.config.model.primary);
|
|
650
|
+
store.refresh();
|
|
651
|
+
}
|
|
652
|
+
/** Inline text boxes (key label/value, endpoint name/url/key, fallback model). */
|
|
653
|
+
handleKeyEntryKey(input, key) {
|
|
654
|
+
const store = this.store;
|
|
655
|
+
const e = store.keyEntry;
|
|
656
|
+
if (!e)
|
|
657
|
+
return;
|
|
658
|
+
const max = e.kind === 'key-value' ? 256 : 128;
|
|
659
|
+
if (key.escape) {
|
|
660
|
+
store.keyEntry = null;
|
|
661
|
+
store.refresh();
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
if (key.return) {
|
|
665
|
+
void this.commitKeyEntry(e);
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
if (key.backspace && e.value.length > 0) {
|
|
669
|
+
const pos = Math.max(0, e.cursorPos - 1);
|
|
670
|
+
e.value = e.value.slice(0, pos) + e.value.slice(e.cursorPos);
|
|
671
|
+
e.cursorPos = pos;
|
|
672
|
+
store.refresh();
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
if (key.delete && e.cursorPos < e.value.length) {
|
|
676
|
+
e.value = e.value.slice(0, e.cursorPos) + e.value.slice(e.cursorPos + 1);
|
|
677
|
+
store.refresh();
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
if (key.leftArrow) {
|
|
681
|
+
e.cursorPos = Math.max(0, e.cursorPos - 1);
|
|
682
|
+
store.refresh();
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
if (key.rightArrow) {
|
|
686
|
+
e.cursorPos = Math.min(e.value.length, e.cursorPos + 1);
|
|
687
|
+
store.refresh();
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
if (key.home) {
|
|
691
|
+
e.cursorPos = 0;
|
|
692
|
+
store.refresh();
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
if (key.end) {
|
|
696
|
+
e.cursorPos = e.value.length;
|
|
697
|
+
store.refresh();
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
if (key.ctrl && input === 'u') {
|
|
701
|
+
e.value = '';
|
|
702
|
+
e.cursorPos = 0;
|
|
703
|
+
store.refresh();
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
if (input && !key.ctrl && !key.meta) {
|
|
707
|
+
if (e.value.length >= max) {
|
|
708
|
+
store.refresh();
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
e.value = e.value.slice(0, e.cursorPos) + input + e.value.slice(e.cursorPos);
|
|
712
|
+
e.cursorPos += input.length;
|
|
713
|
+
store.refresh();
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
async commitKeyEntry(e) {
|
|
717
|
+
const store = this.store;
|
|
718
|
+
const val = e.value.trim();
|
|
719
|
+
switch (e.kind) {
|
|
720
|
+
case 'key-label':
|
|
721
|
+
if (!val) {
|
|
722
|
+
store.keyEntry = null;
|
|
723
|
+
store.refresh();
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
store.keyEntry = {
|
|
727
|
+
kind: 'key-value',
|
|
728
|
+
provider: e.provider,
|
|
729
|
+
value: '',
|
|
730
|
+
cursorPos: 0,
|
|
731
|
+
hint: `paste the ${e.provider} API key (${val})`,
|
|
732
|
+
keyLabel: val,
|
|
733
|
+
};
|
|
734
|
+
store.refresh();
|
|
735
|
+
return;
|
|
736
|
+
case 'key-value': {
|
|
737
|
+
const p = e.provider;
|
|
738
|
+
const err = await saveNamedKey(p, e.keyLabel ?? '', val);
|
|
739
|
+
if (err) {
|
|
740
|
+
this.note(`Could not save key: ${err}`);
|
|
741
|
+
store.keyEntry = null;
|
|
742
|
+
store.refresh();
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
store.keyEntry = null;
|
|
746
|
+
await this.enterModelStage(p);
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
case 'endpoint-name':
|
|
750
|
+
if (!val) {
|
|
751
|
+
store.keyEntry = null;
|
|
752
|
+
store.refresh();
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
store.keyEntry = { kind: 'endpoint-url', provider: val, value: '', cursorPos: 0, hint: 'OpenAI-compatible base URL, e.g. http://127.0.0.1:1234/v1' };
|
|
756
|
+
store.refresh();
|
|
757
|
+
return;
|
|
758
|
+
case 'endpoint-url':
|
|
759
|
+
if (!val.startsWith('http')) {
|
|
760
|
+
this.note('Endpoint base URL must start with http:// or https://');
|
|
761
|
+
store.refresh();
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
store.keyEntry = { kind: 'endpoint-key', provider: e.provider, value: '', cursorPos: 0, hint: 'optional API key (Enter to skip)', baseUrl: val };
|
|
765
|
+
store.refresh();
|
|
766
|
+
return;
|
|
767
|
+
case 'endpoint-key': {
|
|
768
|
+
const name = e.provider;
|
|
769
|
+
const err = await saveEndpoint({ name, baseUrl: e.baseUrl ?? '', key: val || undefined });
|
|
770
|
+
if (err) {
|
|
771
|
+
this.note(`Could not save endpoint: ${err}`);
|
|
772
|
+
store.keyEntry = null;
|
|
773
|
+
store.refresh();
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
store.keyEntry = null;
|
|
777
|
+
// Refresh provider rows so the new endpoint appears, then jump to its models.
|
|
778
|
+
store.pickStage = 'providers';
|
|
779
|
+
await this.refreshModelPicker();
|
|
780
|
+
await this.enterModelStage(`custom:${name}`);
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
case 'endpoint-fallback-model': {
|
|
784
|
+
store.keyEntry = null;
|
|
785
|
+
const name = e.provider;
|
|
786
|
+
if (val) {
|
|
787
|
+
rememberEndpointModel(name, val);
|
|
788
|
+
}
|
|
789
|
+
store.modelPicker.open = false;
|
|
790
|
+
store.refresh();
|
|
791
|
+
void this.selectEndpointModel(name, val);
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
async selectEndpointModel(name, model) {
|
|
797
|
+
if (!model)
|
|
798
|
+
return;
|
|
799
|
+
const full = `${name}/${model}`;
|
|
800
|
+
rememberEndpointModel(name, model);
|
|
801
|
+
const result = await this.onCommand({ type: 'model', value: full });
|
|
802
|
+
if (result === null)
|
|
803
|
+
this.note(`Model → ${full}`);
|
|
804
|
+
}
|
|
558
805
|
/** Step over the row list, skipping non-selectable header rows. */
|
|
559
806
|
movePickerIndex(index, delta) {
|
|
560
807
|
const rows = this.store.pickRows;
|
|
@@ -570,6 +817,13 @@ export class TuiApp {
|
|
|
570
817
|
return index;
|
|
571
818
|
}
|
|
572
819
|
async selectPickerModel(item) {
|
|
820
|
+
// Custom endpoints: id is `<name>/<model>`; just switch the model (the
|
|
821
|
+
// endpoint's provider is registered by name). No provider switch needed.
|
|
822
|
+
if (item.provider.startsWith('custom:')) {
|
|
823
|
+
const name = item.provider.slice('custom:'.length);
|
|
824
|
+
await this.selectEndpointModel(name, item.id.slice(name.length + 1));
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
573
827
|
// Switching provider first lets the same Enter pick a model from a
|
|
574
828
|
// provider that isn't active (e.g. gemini model while on openrouter).
|
|
575
829
|
const currentProvider = this.store.route?.provider;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import { useStore } from '../context.js';
|
|
4
4
|
import { themeColor } from '../colors.js';
|
|
5
5
|
import { isPickerHeader } from '../picker.js';
|
|
6
|
-
/** Model selector overlay (opened by /model). Height includes
|
|
6
|
+
/** Model/provider selector overlay (opened by /model). Height includes border. */
|
|
7
7
|
export function ModelPicker({ height }) {
|
|
8
8
|
const store = useStore();
|
|
9
9
|
const accent = store.theme ? themeColor(store.theme, 'accent') : '#7aa2f7';
|
|
@@ -13,8 +13,15 @@ export function ModelPicker({ height }) {
|
|
|
13
13
|
const rows = store.pickRows;
|
|
14
14
|
const idx = rows.length === 0 ? 0 : Math.min(Math.max(store.modelPicker.index, 0), rows.length - 1);
|
|
15
15
|
const innerH = Math.max(1, height - 3); // border(2) + footer(1)
|
|
16
|
+
const stageLabel = store.pickStage === 'models' ? 'model' : 'provider';
|
|
17
|
+
// Inline key / custom-endpoint entry: show one prompt + the typed value.
|
|
18
|
+
if (store.keyEntry) {
|
|
19
|
+
const ke = store.keyEntry;
|
|
20
|
+
const display = ke.kind === 'key-value' ? (ke.value ? '••••••••'.slice(0, 8) : '') : ke.value;
|
|
21
|
+
return (_jsxs(Box, { borderStyle: "round", borderColor: accent, width: "100%", height: height, flexDirection: "column", paddingX: 1, children: [_jsx(Box, { children: _jsxs(Text, { color: attention, bold: true, wrap: "truncate-end", children: [ke.kind === 'key-label' && `${ke.provider} key — label`, ke.kind === 'key-value' && `${ke.provider} key — value`, ke.kind === 'endpoint-name' && 'custom endpoint — name', ke.kind === 'endpoint-url' && `custom endpoint — base URL`, ke.kind === 'endpoint-key' && `custom endpoint — optional key`, ke.kind === 'endpoint-fallback-model' && `custom endpoint — model id`] }) }), _jsx(Text, { color: muted, wrap: "truncate-end", children: ke.hint }), _jsxs(Box, { children: [_jsx(Text, { color: dim, children: "\u276F " }), _jsx(Text, { color: accent, children: display || ' ' }), _jsx(Text, { color: muted, children: "\u258F" })] }), _jsx(Text, { color: muted, children: "Enter accept \u00B7 Esc cancel" })] }));
|
|
22
|
+
}
|
|
16
23
|
if (rows.length === 0) {
|
|
17
|
-
return (_jsxs(Box, { borderStyle: "round", borderColor: dim, width: "100%", height: height, flexDirection: "column", paddingX: 1, children: [
|
|
24
|
+
return (_jsxs(Box, { borderStyle: "round", borderColor: dim, width: "100%", height: height, flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { color: dim, children: ["no ", stageLabel, "s available \u2014 configure a provider key first"] }), _jsx(Text, { color: muted, children: "Esc back \u00B7 Esc close" })] }));
|
|
18
25
|
}
|
|
19
26
|
const start = Math.max(0, Math.min(idx, rows.length - innerH));
|
|
20
27
|
const windowRows = rows.slice(start, start + innerH);
|
|
@@ -23,6 +30,7 @@ export function ModelPicker({ height }) {
|
|
|
23
30
|
if (isPickerHeader(r)) {
|
|
24
31
|
return (_jsx(Box, { children: _jsx(Text, { color: attention, bold: true, wrap: "truncate-end", children: r.label }) }, `h-${at}`));
|
|
25
32
|
}
|
|
26
|
-
|
|
27
|
-
|
|
33
|
+
const hint = r.hint;
|
|
34
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: at === idx ? accent : dim, children: at === idx ? '❯ ' : ' ' }), _jsx(Text, { color: at === idx ? accent : dim, wrap: "truncate-end", children: r.label }), hint && _jsxs(Text, { color: muted, children: [" ", hint] })] }, r.id ?? `p-${at}`));
|
|
35
|
+
}), start + innerH < rows.length && (_jsxs(Text, { color: muted, children: ["\u25BC ", rows.length - start - innerH, " more\u2026"] })), _jsx(Text, { color: muted, children: store.pickStage === 'models' ? '↑↓ select · Enter switch · Esc back' : '↑↓ select · Enter next · Esc close' })] }));
|
|
28
36
|
}
|
package/dist/src/tui/picker.js
CHANGED
|
@@ -1,59 +1,147 @@
|
|
|
1
1
|
import { PROVIDER_SPECS, ORBITX_SERVE } from '../core/llm/index.js';
|
|
2
|
-
import {
|
|
2
|
+
import { describeProviderKey } from '../core/llm/secrets.js';
|
|
3
3
|
import { createOllamaProvider } from '../core/llm/providers/ollama.js';
|
|
4
|
+
import { loadEndpoints, listEndpointModels } from '../core/llm/endpoints.js';
|
|
4
5
|
export function isPickerHeader(row) {
|
|
5
6
|
return row.kind === 'header';
|
|
6
7
|
}
|
|
7
|
-
|
|
8
|
+
function specLabel(id) {
|
|
9
|
+
return PROVIDER_SPECS.find((p) => p.id === id)?.label ?? id;
|
|
10
|
+
}
|
|
11
|
+
/** Stage 1 — provider rows (sync; no probing here). */
|
|
12
|
+
export function resolveProviderRows() {
|
|
8
13
|
const rows = [];
|
|
9
|
-
const seen = new Set();
|
|
10
|
-
const avail = describeProvidersAvailable();
|
|
11
14
|
for (const spec of PROVIDER_SPECS) {
|
|
12
15
|
if (spec.id === 'ollama') {
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const name = full.slice('ollama/'.length);
|
|
22
|
-
if (seen.has(full))
|
|
23
|
-
continue;
|
|
24
|
-
seen.add(full);
|
|
25
|
-
rows.push({ kind: 'item', label: name, id: full, provider: 'ollama' });
|
|
26
|
-
}
|
|
16
|
+
const keyInfo = describeProviderKey('ollama');
|
|
17
|
+
rows.push({
|
|
18
|
+
kind: 'item',
|
|
19
|
+
providerId: 'ollama',
|
|
20
|
+
label: 'Ollama (local)',
|
|
21
|
+
hint: 'auto-detects a local server · no key required',
|
|
22
|
+
keyInfo: keyInfo === null ? { provider: 'ollama', available: false } : keyInfo,
|
|
23
|
+
});
|
|
27
24
|
continue;
|
|
28
25
|
}
|
|
29
26
|
if (spec.id === 'orbitx') {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
label: bare === 'auto' ? 'auto (backend routes across providers)' : bare,
|
|
39
|
-
id: full,
|
|
40
|
-
provider: 'orbitx',
|
|
41
|
-
});
|
|
42
|
-
}
|
|
27
|
+
const keyInfo = describeProviderKey('orbitx');
|
|
28
|
+
rows.push({
|
|
29
|
+
kind: 'item',
|
|
30
|
+
providerId: 'orbitx',
|
|
31
|
+
label: 'Orbit X (auto-routes Groq · Gemini · OpenRouter)',
|
|
32
|
+
hint: keyInfo?.available ? `token ${keyInfo.masked} · ${keyInfo.source}` : 'token optional — gateway auto-routes',
|
|
33
|
+
keyInfo: keyInfo === null ? { provider: 'orbitx', available: false } : keyInfo,
|
|
34
|
+
});
|
|
43
35
|
continue;
|
|
44
36
|
}
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
37
|
+
const keyInfo = describeProviderKey(spec.id);
|
|
38
|
+
const needsKey = spec.requiresSecret && !keyInfo?.available;
|
|
39
|
+
rows.push({
|
|
40
|
+
kind: 'item',
|
|
41
|
+
providerId: spec.id,
|
|
42
|
+
label: spec.label,
|
|
43
|
+
hint: needsKey
|
|
44
|
+
? 'no key — press Enter to add'
|
|
45
|
+
: keyInfo
|
|
46
|
+
? `key ${keyInfo.masked} · ${keyInfo.source}${keyInfo.label ? ` · ${keyInfo.label}` : ''}`
|
|
47
|
+
: 'no key required',
|
|
48
|
+
needsKey,
|
|
49
|
+
keyInfo: keyInfo ?? null,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
// Named custom OpenAI-compatible endpoints.
|
|
53
|
+
const endpoints = loadEndpoints();
|
|
54
|
+
rows.push({ kind: 'header', label: 'Custom endpoints (OpenAI-compatible)' });
|
|
55
|
+
for (const ep of endpoints) {
|
|
56
|
+
rows.push({
|
|
57
|
+
kind: 'item',
|
|
58
|
+
providerId: `custom:${ep.name}`,
|
|
59
|
+
label: ep.name,
|
|
60
|
+
hint: `${ep.baseUrl}${ep.key ? ' · key set' : ' · no key'}`,
|
|
61
|
+
keyInfo: { provider: ep.name, available: true, source: 'file' },
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
rows.push({
|
|
65
|
+
kind: 'item',
|
|
66
|
+
providerId: '__add_custom__',
|
|
67
|
+
label: '+ add a custom endpoint',
|
|
68
|
+
hint: 'LM Studio · vLLM · any OpenAI-compatible server',
|
|
69
|
+
keyInfo: null,
|
|
70
|
+
});
|
|
71
|
+
return rows;
|
|
72
|
+
}
|
|
73
|
+
/** Stage 2 — model rows for a provider. */
|
|
74
|
+
export async function resolveModelRows(providerId, liveModels) {
|
|
75
|
+
const rows = [];
|
|
76
|
+
if (providerId.startsWith('custom:')) {
|
|
77
|
+
const name = providerId.slice('custom:'.length);
|
|
78
|
+
const ep = loadEndpoints().find((e) => e.name === name);
|
|
79
|
+
if (!ep) {
|
|
80
|
+
rows.push({ kind: 'header', label: 'unknown custom endpoint' });
|
|
81
|
+
return rows;
|
|
82
|
+
}
|
|
83
|
+
rows.push({ kind: 'header', label: `${name} — ${ep.baseUrl}` });
|
|
84
|
+
const live = await listEndpointModels(name);
|
|
85
|
+
const saved = (ep.models ?? []).filter(Boolean);
|
|
86
|
+
const merged = [...new Set([...live, ...saved])];
|
|
87
|
+
if (merged.length === 0) {
|
|
88
|
+
rows.push({
|
|
89
|
+
kind: 'header',
|
|
90
|
+
label: 'no models found — type a model id below (or start the server)',
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
for (const m of merged) {
|
|
95
|
+
rows.push({ kind: 'item', label: m, id: `${name}/${m}`, provider: `custom:${name}` });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return rows;
|
|
99
|
+
}
|
|
100
|
+
if (providerId === 'ollama') {
|
|
101
|
+
const local = await createOllamaProvider().listModels();
|
|
102
|
+
if (local.length === 0) {
|
|
103
|
+
rows.push({ kind: 'header', label: 'Ollama server offline — run `ollama serve`' });
|
|
104
|
+
return rows;
|
|
105
|
+
}
|
|
106
|
+
rows.push({ kind: 'header', label: `Ollama (local) — ${local.length} installed` });
|
|
107
|
+
for (const full of local) {
|
|
108
|
+
rows.push({ kind: 'item', label: full.slice('ollama/'.length), id: full, provider: 'ollama' });
|
|
109
|
+
}
|
|
110
|
+
return rows;
|
|
111
|
+
}
|
|
112
|
+
if (providerId === 'orbitx') {
|
|
113
|
+
rows.push({ kind: 'header', label: 'Orbit X (auto-routes Groq · Gemini · OpenRouter)' });
|
|
114
|
+
// Prefer the LIVE routable list so offered == routable (kills "No model
|
|
115
|
+
// matches"); fall back to the serve table only when no orbitx candidates.
|
|
116
|
+
const live = liveModels.filter((m) => m.startsWith('orbitx/'));
|
|
117
|
+
const serve = live.length > 0 ? live : ORBITX_SERVE.map((b) => `orbitx/${b}`);
|
|
118
|
+
const seen = new Set();
|
|
119
|
+
for (const full of serve) {
|
|
52
120
|
if (seen.has(full))
|
|
53
121
|
continue;
|
|
54
122
|
seen.add(full);
|
|
55
|
-
|
|
123
|
+
const bare = full.slice('orbitx/'.length);
|
|
124
|
+
rows.push({
|
|
125
|
+
kind: 'item',
|
|
126
|
+
label: bare === 'auto' ? 'auto (backend routes across providers)' : bare,
|
|
127
|
+
id: full,
|
|
128
|
+
provider: 'orbitx',
|
|
129
|
+
});
|
|
56
130
|
}
|
|
131
|
+
return rows;
|
|
132
|
+
}
|
|
133
|
+
// Catalog provider.
|
|
134
|
+
const spec = PROVIDER_SPECS.find((p) => p.id === providerId);
|
|
135
|
+
if (!spec) {
|
|
136
|
+
rows.push({ kind: 'header', label: `unknown provider ${providerId}` });
|
|
137
|
+
return rows;
|
|
138
|
+
}
|
|
139
|
+
rows.push({ kind: 'header', label: spec.label });
|
|
140
|
+
for (const m of spec.models) {
|
|
141
|
+
rows.push({ kind: 'item', label: m.label ?? m.id, id: `${spec.id}/${m.id}`, provider: spec.id });
|
|
142
|
+
}
|
|
143
|
+
if (spec.supportsCustom) {
|
|
144
|
+
rows.push({ kind: 'header', label: '— or type a custom model id below' });
|
|
57
145
|
}
|
|
58
146
|
return rows;
|
|
59
147
|
}
|
package/dist/src/tui/store.js
CHANGED
|
@@ -39,6 +39,12 @@ export class AppStore {
|
|
|
39
39
|
cwd = '';
|
|
40
40
|
models = [];
|
|
41
41
|
modelPicker = { open: false, index: 0 };
|
|
42
|
+
/** Which stage of the /model overlay is showing rows: providers or models. */
|
|
43
|
+
pickStage = 'providers';
|
|
44
|
+
/** Provider whose model list is showing in the model stage. */
|
|
45
|
+
pickProviderProviderId = null;
|
|
46
|
+
/** Inline key/custom-endpoint entry overlay (two-stage picker). */
|
|
47
|
+
keyEntry = null;
|
|
42
48
|
/** Grouped rows for the /model overlay (see picker.ts). */
|
|
43
49
|
pickRows = [];
|
|
44
50
|
agents = new Map();
|
|
@@ -42,6 +42,9 @@ export function configPath() {
|
|
|
42
42
|
export function keysPath() {
|
|
43
43
|
return join(configDir(), 'keys.json');
|
|
44
44
|
}
|
|
45
|
+
export function endpointsPath() {
|
|
46
|
+
return join(configDir(), 'endpoints.json');
|
|
47
|
+
}
|
|
45
48
|
export function historyPath() {
|
|
46
49
|
return join(dataDir(), 'history.json');
|
|
47
50
|
}
|
package/package.json
CHANGED