@orbit-intelligence/orbit-agent 0.3.13 → 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 +10 -2
- package/dist/src/config/config-schema.js +11 -1
- package/dist/src/core/agent/agent-loop.js +9 -4
- package/dist/src/core/llm/endpoints.js +110 -0
- package/dist/src/core/llm/http.js +13 -0
- package/dist/src/core/llm/index.js +39 -1
- package/dist/src/core/llm/models.js +175 -58
- package/dist/src/core/llm/providers/anthropic.js +237 -0
- package/dist/src/core/llm/providers/ollama.js +86 -0
- package/dist/src/core/llm/sanitize.js +47 -0
- package/dist/src/core/llm/secrets.js +74 -6
- package/dist/src/setup/wizard.js +7 -1
- package/dist/src/tui/InkApp.js +6 -2
- package/dist/src/tui/app.js +333 -11
- package/dist/src/tui/components/ModelPicker.js +26 -13
- package/dist/src/tui/picker.js +162 -0
- package/dist/src/tui/store.js +8 -0
- package/dist/src/utils/platform.js +3 -0
- package/package.json +1 -1
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { ANTHROPIC_CATALOG } from '../models.js';
|
|
2
|
+
/**
|
|
3
|
+
* Anthropic native streaming provider (Messages API).
|
|
4
|
+
* HTTP: POST https://api.anthropic.com/v1/messages with `x-api-key` +
|
|
5
|
+
* `anthropic-version` headers, SSE stream.
|
|
6
|
+
*
|
|
7
|
+
* Event mapping:
|
|
8
|
+
* text block → token
|
|
9
|
+
* thinking block → reasoning
|
|
10
|
+
* tool_use block → tool_call_start / tool_call_args / tool_call_end
|
|
11
|
+
*/
|
|
12
|
+
const ANTHROPIC_ENDPOINT = 'https://api.anthropic.com/v1/messages';
|
|
13
|
+
const ANTHROPIC_VERSION = '2023-06-01';
|
|
14
|
+
const DEFAULT_MAX_TOKENS = 8192;
|
|
15
|
+
const THINKING_BUDGET = { low: 2048, medium: 8192, high: 32768 };
|
|
16
|
+
const ANTHROPIC_MODELS = ANTHROPIC_CATALOG.map((m) => m.id);
|
|
17
|
+
export function createAnthropicProvider(apiKey) {
|
|
18
|
+
return {
|
|
19
|
+
id: 'anthropic',
|
|
20
|
+
modelCount: ANTHROPIC_MODELS.length,
|
|
21
|
+
listModels() {
|
|
22
|
+
return ANTHROPIC_MODELS.map((m) => `anthropic/${m}`);
|
|
23
|
+
},
|
|
24
|
+
async *stream(llmOpts) {
|
|
25
|
+
let model = llmOpts.model;
|
|
26
|
+
if (model.startsWith('anthropic/'))
|
|
27
|
+
model = model.slice('anthropic/'.length);
|
|
28
|
+
if (!model)
|
|
29
|
+
model = 'claude-sonnet-5';
|
|
30
|
+
const body = {
|
|
31
|
+
model,
|
|
32
|
+
max_tokens: llmOpts.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
33
|
+
messages: toAnthropicMessages(llmOpts.messages),
|
|
34
|
+
stream: true,
|
|
35
|
+
};
|
|
36
|
+
const sys = llmOpts.messages.find((m) => m.role === 'system');
|
|
37
|
+
if (sys?.content)
|
|
38
|
+
body.system = sys.content;
|
|
39
|
+
if (llmOpts.tools?.length)
|
|
40
|
+
body.tools = llmOpts.tools.map(toAnthropicTool);
|
|
41
|
+
if (llmOpts.temperature != null)
|
|
42
|
+
body.temperature = llmOpts.temperature;
|
|
43
|
+
const thinking = anthropicThinkingConfig(llmOpts.reasoning);
|
|
44
|
+
if (thinking)
|
|
45
|
+
body.thinking = thinking;
|
|
46
|
+
const res = await fetch(ANTHROPIC_ENDPOINT, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: {
|
|
49
|
+
'Content-Type': 'application/json',
|
|
50
|
+
'x-api-key': apiKey,
|
|
51
|
+
'anthropic-version': ANTHROPIC_VERSION,
|
|
52
|
+
},
|
|
53
|
+
body: JSON.stringify(body),
|
|
54
|
+
signal: llmOpts.signal,
|
|
55
|
+
});
|
|
56
|
+
if (!res.ok) {
|
|
57
|
+
const text = await res.text().catch(() => 'unknown');
|
|
58
|
+
throw new Error(`Anthropic HTTP ${res.status}: ${text.slice(0, 300)}`);
|
|
59
|
+
}
|
|
60
|
+
const reader = res.body?.getReader();
|
|
61
|
+
if (!reader)
|
|
62
|
+
throw new Error('No readable body from Anthropic');
|
|
63
|
+
const usage = { input: 0, output: 0 };
|
|
64
|
+
const blocks = new Map();
|
|
65
|
+
let sentDone = false;
|
|
66
|
+
const decoder = new TextDecoder();
|
|
67
|
+
let buffer = '';
|
|
68
|
+
let eventType = '';
|
|
69
|
+
try {
|
|
70
|
+
while (true) {
|
|
71
|
+
const { done, value } = await reader.read();
|
|
72
|
+
if (done)
|
|
73
|
+
break;
|
|
74
|
+
buffer += decoder.decode(value, { stream: true });
|
|
75
|
+
while (true) {
|
|
76
|
+
const nl = buffer.indexOf('\n');
|
|
77
|
+
if (nl === -1)
|
|
78
|
+
break;
|
|
79
|
+
const line = buffer.slice(0, nl);
|
|
80
|
+
buffer = buffer.slice(nl + 1);
|
|
81
|
+
if (line.startsWith('event:')) {
|
|
82
|
+
eventType = line.slice(6).trim();
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (!line.startsWith('data:'))
|
|
86
|
+
continue;
|
|
87
|
+
const data = line.slice(5).trim();
|
|
88
|
+
if (!data)
|
|
89
|
+
continue;
|
|
90
|
+
const parsed = jsonSafe(data);
|
|
91
|
+
if (!parsed)
|
|
92
|
+
continue;
|
|
93
|
+
for (const ev of handleAnthropicEvent(parsed, eventType, blocks, usage)) {
|
|
94
|
+
yield ev;
|
|
95
|
+
if (ev.type === 'done')
|
|
96
|
+
sentDone = true;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
reader.releaseLock();
|
|
103
|
+
}
|
|
104
|
+
if (!sentDone)
|
|
105
|
+
yield { type: 'done', usage: { inputTokens: usage.input, outputTokens: usage.output } };
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function toAnthropicMessages(messages) {
|
|
110
|
+
const out = [];
|
|
111
|
+
for (const m of messages) {
|
|
112
|
+
if (m.role === 'system')
|
|
113
|
+
continue;
|
|
114
|
+
if (m.role === 'assistant') {
|
|
115
|
+
const blocks = [];
|
|
116
|
+
if (m.content)
|
|
117
|
+
blocks.push({ type: 'text', text: m.content });
|
|
118
|
+
for (const tc of m.toolCalls ?? []) {
|
|
119
|
+
blocks.push({ type: 'tool_use', id: tc.id, name: tc.name, input: safeJson(tc.args) });
|
|
120
|
+
}
|
|
121
|
+
out.push({ role: 'assistant', content: blocks });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (m.toolResults && m.toolResults.length > 0) {
|
|
125
|
+
const blocks = [];
|
|
126
|
+
for (const r of m.toolResults) {
|
|
127
|
+
blocks.push({ type: 'tool_result', tool_use_id: r.toolCallId, content: r.content });
|
|
128
|
+
}
|
|
129
|
+
out.push({ role: 'user', content: blocks });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
out.push({ role: 'user', content: m.content });
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
function toAnthropicTool(t) {
|
|
137
|
+
return {
|
|
138
|
+
name: t.function.name,
|
|
139
|
+
description: t.function.description,
|
|
140
|
+
input_schema: t.function.parameters ?? { type: 'object', properties: {} },
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function anthropicThinkingConfig(reasoning) {
|
|
144
|
+
if (!reasoning)
|
|
145
|
+
return undefined;
|
|
146
|
+
const effort = reasoning.effort ?? 'medium';
|
|
147
|
+
if (reasoning.enabled === false || effort === 'none')
|
|
148
|
+
return { type: 'disabled' };
|
|
149
|
+
return { type: 'enabled', budget_tokens: THINKING_BUDGET[effort] ?? THINKING_BUDGET['medium'] };
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Handle a single Anthropic SSE data payload, mutating block/usage state and
|
|
153
|
+
* returning the StreamEvents to surface. Empty for non-generating events.
|
|
154
|
+
*/
|
|
155
|
+
function* handleAnthropicEvent(parsed, _eventType, blocks, usage) {
|
|
156
|
+
const evt = parsed.type;
|
|
157
|
+
if (evt === 'message_start') {
|
|
158
|
+
const msg = parsed.message;
|
|
159
|
+
usage.input = msg?.usage?.input_tokens ?? 0;
|
|
160
|
+
usage.output = msg?.usage?.output_tokens ?? 0;
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (evt === 'content_block_start') {
|
|
164
|
+
const index = parsed.index ?? 0;
|
|
165
|
+
const block = parsed.content_block;
|
|
166
|
+
const type = block?.type;
|
|
167
|
+
if (type === 'tool_use') {
|
|
168
|
+
const id = block?.id ?? `claude_${Date.now()}_${block?.name ?? 'tool'}`;
|
|
169
|
+
blocks.set(index, { kind: 'tool_use', id, name: block?.name ?? '', args: '' });
|
|
170
|
+
yield { type: 'tool_call_start', id, name: block?.name ?? '' };
|
|
171
|
+
}
|
|
172
|
+
else if (type === 'text' || type === 'thinking') {
|
|
173
|
+
blocks.set(index, { kind: type, id: '', name: '', args: '' });
|
|
174
|
+
}
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (evt === 'content_block_delta') {
|
|
178
|
+
const index = parsed.index ?? 0;
|
|
179
|
+
const delta = parsed.delta;
|
|
180
|
+
const dtype = delta?.type;
|
|
181
|
+
if (dtype === 'text_delta' && typeof delta?.text === 'string') {
|
|
182
|
+
yield { type: 'token', text: delta.text };
|
|
183
|
+
}
|
|
184
|
+
else if (dtype === 'thinking_delta' && typeof delta?.thinking === 'string') {
|
|
185
|
+
yield { type: 'reasoning', text: delta.thinking };
|
|
186
|
+
}
|
|
187
|
+
else if (dtype === 'input_json_delta' && typeof delta?.partial_json === 'string') {
|
|
188
|
+
const block = blocks.get(index);
|
|
189
|
+
if (block && block.kind === 'tool_use') {
|
|
190
|
+
block.args += delta.partial_json;
|
|
191
|
+
if (block.id)
|
|
192
|
+
yield { type: 'tool_call_args', id: block.id, args: delta.partial_json };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (evt === 'content_block_stop') {
|
|
198
|
+
const index = parsed.index ?? 0;
|
|
199
|
+
const block = blocks.get(index);
|
|
200
|
+
if (block && block.kind === 'tool_use') {
|
|
201
|
+
yield { type: 'tool_call_end', id: block.id, name: block.name, args: block.args };
|
|
202
|
+
}
|
|
203
|
+
blocks.delete(index);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (evt === 'message_delta') {
|
|
207
|
+
const mdUsage = parsed.usage;
|
|
208
|
+
if (mdUsage?.output_tokens != null)
|
|
209
|
+
usage.output = mdUsage.output_tokens;
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (evt === 'message_stop') {
|
|
213
|
+
yield { type: 'done', usage: { inputTokens: usage.input, outputTokens: usage.output } };
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (evt === 'error') {
|
|
217
|
+
const err = parsed.error;
|
|
218
|
+
throw new Error(`Anthropic: ${err?.message ?? JSON.stringify(parsed.error) ?? 'stream error'}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function safeJson(s) {
|
|
222
|
+
try {
|
|
223
|
+
const v = JSON.parse(s);
|
|
224
|
+
return v && typeof v === 'object' ? v : {};
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return {};
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function jsonSafe(s) {
|
|
231
|
+
try {
|
|
232
|
+
return JSON.parse(s);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { streamOpenAi } from '../http.js';
|
|
2
|
+
import { OLLAMA_CATALOG } from '../models.js';
|
|
3
|
+
/**
|
|
4
|
+
* Ollama (local, OpenAI-compatible) provider.
|
|
5
|
+
*
|
|
6
|
+
* No API key: Ollama accepts both a dummy key and an empty Authorization.
|
|
7
|
+
* Default base http://127.0.0.1:11434 (Android/Termux), overridable via the
|
|
8
|
+
* OLLAMA_HOST env var. Installed models are discovered by probing
|
|
9
|
+
* GET /api/tags (async, short-cached, fast-fail when the server is down).
|
|
10
|
+
*/
|
|
11
|
+
export const OLLAMA_DEFAULT_BASE = 'http://127.0.0.1:11434';
|
|
12
|
+
const TAGS_CACHE_MS = 5000;
|
|
13
|
+
const TAGS_TIMEOUT_MS = 4000;
|
|
14
|
+
let tagsCache = null;
|
|
15
|
+
export function ollamaBaseUrl(explicit) {
|
|
16
|
+
return (explicit?.trim() || process.env.OLLAMA_HOST?.trim() || OLLAMA_DEFAULT_BASE).replace(/\/+$/, '');
|
|
17
|
+
}
|
|
18
|
+
async function probeTags(baseUrl) {
|
|
19
|
+
const now = Date.now();
|
|
20
|
+
if (tagsCache && now - tagsCache.at < TAGS_CACHE_MS)
|
|
21
|
+
return tagsCache.names;
|
|
22
|
+
try {
|
|
23
|
+
const ctrl = new AbortController();
|
|
24
|
+
const timer = setTimeout(() => ctrl.abort(new Error('ollama probe timed out')), TAGS_TIMEOUT_MS);
|
|
25
|
+
const res = await fetch(`${baseUrl}/api/tags`, { signal: ctrl.signal });
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
if (!res.ok)
|
|
28
|
+
throw new Error(`HTTP ${res.status}`);
|
|
29
|
+
const json = (await res.json());
|
|
30
|
+
const names = dedupeStable((json.models ?? []).map((m) => m.name).filter(Boolean));
|
|
31
|
+
tagsCache = { at: Date.now(), names };
|
|
32
|
+
return names;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
tagsCache = { at: Date.now(), names: [] };
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Collapse `foo:latest` → `foo` while preserving distinct non-latest tags. */
|
|
40
|
+
function dedupeStable(list) {
|
|
41
|
+
const seen = new Set();
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const n of list) {
|
|
44
|
+
const clean = n.endsWith(':latest') ? n.slice(0, n.length - 7) : n;
|
|
45
|
+
if (!seen.has(clean)) {
|
|
46
|
+
seen.add(clean);
|
|
47
|
+
out.push(clean);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
export function createOllamaProvider(opts = {}) {
|
|
53
|
+
const baseUrl = ollamaBaseUrl(opts.baseUrl);
|
|
54
|
+
const catalog = opts.models && opts.models.length > 0 ? opts.models : OLLAMA_CATALOG.map((m) => m.id);
|
|
55
|
+
return {
|
|
56
|
+
id: 'ollama',
|
|
57
|
+
modelCount: catalog.length,
|
|
58
|
+
async listModels() {
|
|
59
|
+
const names = await probeTags(baseUrl);
|
|
60
|
+
// Empty when the server is unreachable so routing/pickers don't list
|
|
61
|
+
// phantom models; the static catalog is only a wizard fallback.
|
|
62
|
+
return names.map((n) => `ollama/${n}`);
|
|
63
|
+
},
|
|
64
|
+
async *stream(llmOpts) {
|
|
65
|
+
let model = llmOpts.model;
|
|
66
|
+
if (model.startsWith('ollama/'))
|
|
67
|
+
model = model.slice('ollama/'.length);
|
|
68
|
+
if (!model)
|
|
69
|
+
model = catalog[0] ?? 'llama3.2';
|
|
70
|
+
yield* streamOpenAi({
|
|
71
|
+
baseUrl: `${baseUrl}/v1`,
|
|
72
|
+
apiKey: '',
|
|
73
|
+
model,
|
|
74
|
+
messages: llmOpts.messages,
|
|
75
|
+
tools: llmOpts.tools,
|
|
76
|
+
temperature: llmOpts.temperature,
|
|
77
|
+
maxTokens: llmOpts.maxTokens,
|
|
78
|
+
signal: llmOpts.signal,
|
|
79
|
+
reasoning: llmOpts.reasoning,
|
|
80
|
+
// Modern Ollama accepts `thinking` + `reasoning_effort` in the OpenAI
|
|
81
|
+
// endpoint; unknown extra keys are ignored by older builds.
|
|
82
|
+
reasoningStyle: 'deepseek',
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -12,6 +12,10 @@ const ENV_LABELS = {
|
|
|
12
12
|
groq: ['GROQ_API_KEY'],
|
|
13
13
|
gemini: ['GEMINI_API_KEY'],
|
|
14
14
|
openrouter: ['OPENROUTER_API_KEY'],
|
|
15
|
+
openai: ['OPENAI_API_KEY'],
|
|
16
|
+
anthropic: ['ANTHROPIC_API_KEY'],
|
|
17
|
+
grok: ['XAI_API_KEY', 'GROK_API_KEY'],
|
|
18
|
+
deepseek: ['DEEPSEEK_API_KEY'],
|
|
15
19
|
};
|
|
16
20
|
// The setup wizard calls providers three times in quick succession; don't
|
|
17
21
|
// re-read + re-parse + re-chmod the keys file on every lookup.
|
|
@@ -65,7 +69,13 @@ function fromEnv(provider) {
|
|
|
65
69
|
return keys;
|
|
66
70
|
}
|
|
67
71
|
export function isImportableDirectly(p) {
|
|
68
|
-
return p === 'groq' ||
|
|
72
|
+
return (p === 'groq' ||
|
|
73
|
+
p === 'gemini' ||
|
|
74
|
+
p === 'openrouter' ||
|
|
75
|
+
p === 'openai' ||
|
|
76
|
+
p === 'anthropic' ||
|
|
77
|
+
p === 'grok' ||
|
|
78
|
+
p === 'deepseek');
|
|
69
79
|
}
|
|
70
80
|
export function getProviderSecrets(provider) {
|
|
71
81
|
const envKeys = fromEnv(provider);
|
|
@@ -73,12 +83,13 @@ export function getProviderSecrets(provider) {
|
|
|
73
83
|
return { provider, keys: envKeys, source: 'env' };
|
|
74
84
|
const file = readKeysFile();
|
|
75
85
|
const fileKeys = file[provider] ?? [];
|
|
76
|
-
|
|
77
|
-
|
|
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' };
|
|
78
89
|
return null;
|
|
79
90
|
}
|
|
80
91
|
export function hasAnySecrets() {
|
|
81
|
-
for (const p of ['orbitx', 'groq', 'gemini', 'openrouter']) {
|
|
92
|
+
for (const p of ['orbitx', 'groq', 'gemini', 'openrouter', 'openai', 'anthropic', 'grok', 'deepseek']) {
|
|
82
93
|
if (getProviderSecrets(p))
|
|
83
94
|
return true;
|
|
84
95
|
}
|
|
@@ -91,19 +102,37 @@ export function maskSecret(key) {
|
|
|
91
102
|
}
|
|
92
103
|
export function describeProvidersAvailable() {
|
|
93
104
|
const out = [];
|
|
94
|
-
for (const p of ['orbitx', 'groq', 'gemini', 'openrouter']) {
|
|
105
|
+
for (const p of ['orbitx', 'groq', 'gemini', 'openrouter', 'openai', 'anthropic', 'grok', 'deepseek']) {
|
|
95
106
|
const s = getProviderSecrets(p);
|
|
96
107
|
if (s && s.keys[0])
|
|
97
108
|
out.push({ provider: p, masked: maskSecret(s.keys[0]), source: s.source });
|
|
98
109
|
}
|
|
99
110
|
return out;
|
|
100
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
|
+
}
|
|
101
125
|
/**
|
|
102
126
|
* Persist provider keys to the git-ignored keys.json (0600), merging over any
|
|
103
127
|
* existing keys so a re-run of the wizard never wipes other providers.
|
|
104
128
|
*/
|
|
105
129
|
export function writeKeys(provider, keys) {
|
|
106
|
-
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) !== '');
|
|
107
136
|
if (trimmed.length === 0)
|
|
108
137
|
return 'no keys provided';
|
|
109
138
|
const existing = readKeysFile();
|
|
@@ -119,3 +148,42 @@ export function writeKeys(provider, keys) {
|
|
|
119
148
|
return err.message;
|
|
120
149
|
}
|
|
121
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/setup/wizard.js
CHANGED
|
@@ -252,7 +252,13 @@ function contentLines(state, theme, width) {
|
|
|
252
252
|
lines.push('');
|
|
253
253
|
PROVIDER_SPECS.forEach((p, i) => {
|
|
254
254
|
const av = describeProvidersAvailable().some((a) => a.provider === p.id);
|
|
255
|
-
const flag = p.id === 'orbitx'
|
|
255
|
+
const flag = p.id === 'orbitx'
|
|
256
|
+
? ' free · auto-routes Groq/Gemini/OpenRouter'
|
|
257
|
+
: p.id === 'ollama'
|
|
258
|
+
? ' local · auto-detect http://127.0.0.1:11434'
|
|
259
|
+
: av
|
|
260
|
+
? ' ✓ available'
|
|
261
|
+
: ` ${dimSgr}(keys not set)${reset}`;
|
|
256
262
|
if (i === state.providerSelected)
|
|
257
263
|
lines.push(`${accentSgr}▸ ${p.label}${reset}${dimSgr}${flag}${reset}`);
|
|
258
264
|
else
|
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
|
|
@@ -63,5 +67,5 @@ export function InkApp({ controller }) {
|
|
|
63
67
|
revealChars: store.revealChars,
|
|
64
68
|
});
|
|
65
69
|
}
|
|
66
|
-
return (_jsx(StoreContext.Provider, { value: store, children: _jsxs(Box, { width: cols, height: bodyRows, flexDirection: "column", overflow: "hidden", children: [_jsx(Header, { width: cols }), _jsx(Text, { dimColor: true, children: 'Tip: /help for commands · /theme to switch' }), _jsx(Transcript, { rows: msgRows, viewport: viewport }), working && _jsx(WorkingStatus, {}), store.pendingAsk && _jsx(PermissionModal, { cols: cols }), store.dockOpen && _jsx(AgentDock, { width: cols, height: dockLines }), menuing && _jsx(SlashMenu, { width: cols }), store.modelPicker.open && _jsx(ModelPicker, {
|
|
70
|
+
return (_jsx(StoreContext.Provider, { value: store, children: _jsxs(Box, { width: cols, height: bodyRows, flexDirection: "column", overflow: "hidden", children: [_jsx(Header, { width: cols }), _jsx(Text, { dimColor: true, children: 'Tip: /help for commands · /theme to switch' }), _jsx(Transcript, { rows: msgRows, viewport: viewport }), working && _jsx(WorkingStatus, {}), store.pendingAsk && _jsx(PermissionModal, { cols: cols }), store.dockOpen && _jsx(AgentDock, { width: cols, height: dockLines }), menuing && _jsx(SlashMenu, { width: cols }), store.modelPicker.open && _jsx(ModelPicker, { height: modelLines }), _jsx(Box, { height: 1, children: _jsx(Text, { dimColor: true, wrap: "truncate-end", children: '─'.repeat(Math.max(1, cols)) }) }), _jsx(Composer, {}), _jsx(StatusLine, { width: cols })] }) }));
|
|
67
71
|
}
|