@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.
@@ -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 { resolvePickerRows } from '../tui/picker.js';
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: () => resolvePickerRows(),
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
- asstMsg.content += ev.text;
91
- bus.emit('onToken', ev.text);
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
- asstMsg.reasoning = (asstMsg.reasoning ?? '') + ev.text;
96
- bus.emit('onThinking', ev.text);
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
- if (fileKeys.length > 0)
87
- return { provider, keys: fileKeys, source: 'file' };
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.map((k) => k.trim()).filter(Boolean);
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
+ }
@@ -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 ? Math.min(store.pickRows.length, 12) + 3 : 0;
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
@@ -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
- /** Rebuild the grouped picker rows (e.g. after a provider/model switch). */
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
- if (!this.pickBuilder)
93
- return;
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
- this.store.pickRows = rows;
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
- this.store.modelPicker.index = 0;
99
- else if (this.store.modelPicker.index >= rows.length)
100
- this.store.modelPicker.index = rows.length - 1;
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
- this.store.pickRows = [];
118
+ store.pickRows = [];
104
119
  }
105
120
  }
106
- /** Open the /model overlay, refreshing rows first so the list is current. */
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
- if (store.pickRows.length === 0 && store.models.length > 0) {
111
- // Fallback when no builder: derive item rows from the routed models.
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 { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
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 the border. */
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: [_jsx(Text, { color: dim, children: "no models available \u2014 configure a provider key first" }), _jsx(Text, { color: muted, children: "Esc close" })] }));
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
- 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 })] }, r.id));
27
- }), start + innerH < rows.length && (_jsxs(Text, { color: muted, children: ["\u25BC ", rows.length - start - innerH, " more\u2026"] })), _jsx(Text, { color: muted, children: "\u2191\u2193 select \u00B7 Enter switch \u00B7 Esc close" })] }));
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
  }
@@ -1,59 +1,147 @@
1
1
  import { PROVIDER_SPECS, ORBITX_SERVE } from '../core/llm/index.js';
2
- import { describeProvidersAvailable } from '../core/llm/secrets.js';
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
- export async function resolvePickerRows() {
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 ollama = createOllamaProvider();
14
- const local = await ollama.listModels();
15
- if (local.length === 0) {
16
- rows.push({ kind: 'header', label: 'Ollama (local) — server offline, run ollama serve' });
17
- continue;
18
- }
19
- rows.push({ kind: 'header', label: `Ollama (local) — ${local.length} installed` });
20
- for (const full of local) {
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
- rows.push({ kind: 'header', label: 'Orbit X (auto-routes Groq · Gemini · OpenRouter)' });
31
- for (const bare of ORBITX_SERVE) {
32
- const full = `orbitx/${bare}`;
33
- if (seen.has(full))
34
- continue;
35
- seen.add(full);
36
- rows.push({
37
- kind: 'item',
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
- // Other providers: only when a key is configured.
46
- const available = avail.some((a) => a.provider === spec.id);
47
- if (!available)
48
- continue;
49
- rows.push({ kind: 'header', label: spec.label });
50
- for (const m of spec.models) {
51
- const full = `${spec.id}/${m.id}`;
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
- rows.push({ kind: 'item', label: m.label ?? m.id, id: full, provider: spec.id });
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
  }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orbit-intelligence/orbit-agent",
3
- "version": "0.3.14",
3
+ "version": "0.3.15",
4
4
  "description": "orbit — a premium pure-TypeScript coding agent for Termux and desktop terminals, powered by Orbit X.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",