@orbit-intelligence/orbit-agent 0.3.12
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/LICENSE +16 -0
- package/README.md +23 -0
- package/bin/orbit +26 -0
- package/dist/prompts/system.js +80 -0
- package/dist/src/cli/args.js +145 -0
- package/dist/src/cli/orchestrate.js +100 -0
- package/dist/src/cli/run.js +393 -0
- package/dist/src/config/config-schema.js +151 -0
- package/dist/src/config/index.js +57 -0
- package/dist/src/core/agent/agent-loop.js +402 -0
- package/dist/src/core/agents/delegate.js +120 -0
- package/dist/src/core/agents/orchestrator.js +58 -0
- package/dist/src/core/agents/prompts.js +82 -0
- package/dist/src/core/agents/types.js +1 -0
- package/dist/src/core/context/context-manager.js +167 -0
- package/dist/src/core/events.js +23 -0
- package/dist/src/core/llm/http.js +207 -0
- package/dist/src/core/llm/index.js +93 -0
- package/dist/src/core/llm/models.js +228 -0
- package/dist/src/core/llm/providers/gemini.js +211 -0
- package/dist/src/core/llm/providers/openai-compat.js +31 -0
- package/dist/src/core/llm/router.js +125 -0
- package/dist/src/core/llm/secrets.js +121 -0
- package/dist/src/core/llm/types.js +10 -0
- package/dist/src/core/orchestration/dispatcher.js +74 -0
- package/dist/src/core/orchestration/messenger.js +139 -0
- package/dist/src/core/orchestration/roles.js +129 -0
- package/dist/src/core/orchestration/runtime.js +122 -0
- package/dist/src/core/orchestration/session.js +204 -0
- package/dist/src/core/orchestration/shared-context.js +88 -0
- package/dist/src/core/orchestration/tools.js +187 -0
- package/dist/src/core/orchestration/types.js +3 -0
- package/dist/src/core/permissions/index.js +58 -0
- package/dist/src/core/project-context.js +115 -0
- package/dist/src/core/skill-loader.js +31 -0
- package/dist/src/core/tools/edit.js +142 -0
- package/dist/src/core/tools/filesystem.js +203 -0
- package/dist/src/core/tools/git.js +138 -0
- package/dist/src/core/tools/registry.js +73 -0
- package/dist/src/core/tools/search.js +90 -0
- package/dist/src/core/tools/shell.js +65 -0
- package/dist/src/core/tools/types.js +6 -0
- package/dist/src/core/types.js +3 -0
- package/dist/src/index.js +11 -0
- package/dist/src/session/event-log.js +55 -0
- package/dist/src/session/store.js +76 -0
- package/dist/src/setup/wizard.js +401 -0
- package/dist/src/tui/InkApp.js +67 -0
- package/dist/src/tui/ansi.js +142 -0
- package/dist/src/tui/app.js +768 -0
- package/dist/src/tui/colors.js +13 -0
- package/dist/src/tui/components/AgentDock.js +46 -0
- package/dist/src/tui/components/Composer.js +35 -0
- package/dist/src/tui/components/Header.js +23 -0
- package/dist/src/tui/components/ModelPicker.js +23 -0
- package/dist/src/tui/components/PermissionModal.js +29 -0
- package/dist/src/tui/components/SlashMenu.js +15 -0
- package/dist/src/tui/components/StatusLine.js +27 -0
- package/dist/src/tui/components/Transcript.js +31 -0
- package/dist/src/tui/components/WorkingStatus.js +29 -0
- package/dist/src/tui/components/input.js +246 -0
- package/dist/src/tui/components/markdown.js +384 -0
- package/dist/src/tui/components/message.js +105 -0
- package/dist/src/tui/context.js +8 -0
- package/dist/src/tui/geometry.js +40 -0
- package/dist/src/tui/renderer.js +116 -0
- package/dist/src/tui/rows.js +247 -0
- package/dist/src/tui/scheduler.js +32 -0
- package/dist/src/tui/store.js +127 -0
- package/dist/src/tui/style.js +151 -0
- package/dist/src/tui/term.js +309 -0
- package/dist/src/tui/text.js +104 -0
- package/dist/src/tui/themes/index.js +15 -0
- package/dist/src/tui/themes/palettes.js +137 -0
- package/dist/src/tui/themes/types.js +1 -0
- package/dist/src/utils/diff.js +161 -0
- package/dist/src/utils/platform.js +71 -0
- package/dist/src/utils/signals.js +26 -0
- package/dist/src/version.js +4 -0
- package/package.json +71 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { newMessage } from '../types.js';
|
|
2
|
+
export class ContextManager {
|
|
3
|
+
messages = [];
|
|
4
|
+
systemPrompt = '';
|
|
5
|
+
constructor(systemPrompt = '') {
|
|
6
|
+
this.systemPrompt = systemPrompt;
|
|
7
|
+
}
|
|
8
|
+
setSystemPrompt(prompt) {
|
|
9
|
+
this.systemPrompt = prompt;
|
|
10
|
+
}
|
|
11
|
+
reset() {
|
|
12
|
+
this.messages = [];
|
|
13
|
+
}
|
|
14
|
+
history() {
|
|
15
|
+
return this.messages;
|
|
16
|
+
}
|
|
17
|
+
add(msg) {
|
|
18
|
+
this.messages.push(msg);
|
|
19
|
+
}
|
|
20
|
+
addUser(text) {
|
|
21
|
+
const msg = newMessage(`u_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, 'user', text);
|
|
22
|
+
this.messages.push(msg);
|
|
23
|
+
return msg;
|
|
24
|
+
}
|
|
25
|
+
addAssistant(content, extras) {
|
|
26
|
+
const msg = newMessage(`a_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, 'assistant', content);
|
|
27
|
+
if (extras?.model)
|
|
28
|
+
msg.model = extras.model;
|
|
29
|
+
if (extras?.toolCalls)
|
|
30
|
+
msg.toolCalls = extras.toolCalls;
|
|
31
|
+
this.messages.push(msg);
|
|
32
|
+
return msg;
|
|
33
|
+
}
|
|
34
|
+
/** Record a tool result against the tool call that produced it. */
|
|
35
|
+
addToolResult(call, content, isError) {
|
|
36
|
+
const msg = newMessage(`r_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, 'tool', content);
|
|
37
|
+
msg.toolCallId = call.id;
|
|
38
|
+
msg.toolName = call.name;
|
|
39
|
+
msg.isError = isError;
|
|
40
|
+
this.messages.push(msg);
|
|
41
|
+
}
|
|
42
|
+
/** Convert to provider wire format, pairing tool results with their calls. */
|
|
43
|
+
toOutgoing() {
|
|
44
|
+
const out = [];
|
|
45
|
+
if (this.systemPrompt)
|
|
46
|
+
out.push({ role: 'system', content: this.systemPrompt });
|
|
47
|
+
for (const m of this.messages) {
|
|
48
|
+
if (m.role === 'user') {
|
|
49
|
+
out.push({ role: 'user', content: m.content });
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (m.role === 'assistant') {
|
|
53
|
+
out.push({
|
|
54
|
+
role: 'assistant',
|
|
55
|
+
content: m.content,
|
|
56
|
+
toolCalls: m.toolCalls?.map((tc) => ({ id: tc.id, name: tc.name, args: tc.args })),
|
|
57
|
+
});
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (m.role === 'tool') {
|
|
61
|
+
const tr = m;
|
|
62
|
+
let last = out[out.length - 1];
|
|
63
|
+
// find the preceding assistant turn
|
|
64
|
+
let idx = out.length - 1;
|
|
65
|
+
while (idx >= 0 && out[idx].role !== 'assistant')
|
|
66
|
+
idx--;
|
|
67
|
+
last = out[idx] ?? out[out.length - 1];
|
|
68
|
+
if (last && tr.toolCallId) {
|
|
69
|
+
const existing = last.toolResults ?? [];
|
|
70
|
+
last.toolResults = [
|
|
71
|
+
...existing,
|
|
72
|
+
{ toolCallId: tr.toolCallId, name: tr.toolName ?? '', content: tr.content, isError: tr.isError ?? false },
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
// system messages already handled via systemPrompt
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Rough token estimate of the whole conversation (system prompt included).
|
|
83
|
+
* Hedged: ~4 characters per token, plus 1 token per message for framing.
|
|
84
|
+
*/
|
|
85
|
+
estimateTokens() {
|
|
86
|
+
let chars = this.systemPrompt.length;
|
|
87
|
+
let count = this.systemPrompt ? 1 : 0;
|
|
88
|
+
for (const m of this.messages) {
|
|
89
|
+
chars += m.content.length;
|
|
90
|
+
count++;
|
|
91
|
+
for (const tc of m.toolCalls ?? []) {
|
|
92
|
+
chars += tc.name.length + tc.args.length;
|
|
93
|
+
count++;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return Math.ceil(chars / 4) + count;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Keep the conversation within `budget` estimated tokens. Adapted from
|
|
100
|
+
* opencode's compaction strategy: reserve a recent-context budget (25% of
|
|
101
|
+
* `budget`, clamped to [2000, 15000]) for the newest turns and drop the
|
|
102
|
+
* older history that exceeds that window.
|
|
103
|
+
*
|
|
104
|
+
* The current (most recent) turn is always preserved so the active question
|
|
105
|
+
* keeps its context. Returns the number of dropped user turns and the token
|
|
106
|
+
* delta, or zeros when no compaction was needed.
|
|
107
|
+
*/
|
|
108
|
+
compact(budget) {
|
|
109
|
+
const tokensBefore = this.estimateTokens();
|
|
110
|
+
if (tokensBefore <= budget)
|
|
111
|
+
return { droppedPairs: 0, tokensBefore, tokensAfter: tokensBefore };
|
|
112
|
+
// Identify complete turn groups: [user, assistant, (tool…)*]
|
|
113
|
+
const groups = [];
|
|
114
|
+
let current = [];
|
|
115
|
+
for (let i = 0; i < this.messages.length; i++) {
|
|
116
|
+
const m = this.messages[i];
|
|
117
|
+
if (m.role === 'user' && current.length > 0) {
|
|
118
|
+
groups.push(current);
|
|
119
|
+
current = [i];
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
current.push(i);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (current.length > 0)
|
|
126
|
+
groups.push(current);
|
|
127
|
+
// Nothing to drop when there is a single turn (the current one).
|
|
128
|
+
if (groups.length <= 1)
|
|
129
|
+
return { droppedPairs: 0, tokensBefore, tokensAfter: tokensBefore };
|
|
130
|
+
// Reserve a budget for the newest turns, walking oldest-→-newest.
|
|
131
|
+
const reserved = Math.min(15_000, Math.max(2_000, Math.floor(budget * 0.25)));
|
|
132
|
+
const last = groups[groups.length - 1];
|
|
133
|
+
let keepBegin = last[0];
|
|
134
|
+
let cumulative = this.estimateRange(last[0], last[last.length - 1]);
|
|
135
|
+
for (let g = groups.length - 2; g >= 0; g--) {
|
|
136
|
+
const group = groups[g];
|
|
137
|
+
const size = this.estimateRange(group[0], group[group.length - 1]);
|
|
138
|
+
if (cumulative + size <= reserved) {
|
|
139
|
+
cumulative += size;
|
|
140
|
+
keepBegin = group[0];
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
const dropped = this.messages.slice(0, keepBegin).filter((m) => m.role === 'user').length;
|
|
146
|
+
if (dropped === 0)
|
|
147
|
+
return { droppedPairs: 0, tokensBefore, tokensAfter: tokensBefore };
|
|
148
|
+
this.messages.splice(0, keepBegin);
|
|
149
|
+
const tokensAfter = this.estimateTokens();
|
|
150
|
+
return { droppedPairs: dropped, tokensBefore, tokensAfter };
|
|
151
|
+
}
|
|
152
|
+
/** Character-based token estimate for messages[indexA..indexB], inclusive. */
|
|
153
|
+
estimateRange(indexA, indexB) {
|
|
154
|
+
let chars = 0;
|
|
155
|
+
let count = 0;
|
|
156
|
+
for (let i = indexA; i <= indexB && i < this.messages.length; i++) {
|
|
157
|
+
const m = this.messages[i];
|
|
158
|
+
chars += m.content.length;
|
|
159
|
+
count++;
|
|
160
|
+
for (const tc of m.toolCalls ?? []) {
|
|
161
|
+
chars += tc.name.length + tc.args.length;
|
|
162
|
+
count++;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return Math.ceil(chars / 4) + count;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export class EventBus {
|
|
2
|
+
listeners = new Map();
|
|
3
|
+
on(name, handler) {
|
|
4
|
+
const set = this.listeners.get(name) ?? new Set();
|
|
5
|
+
set.add(handler);
|
|
6
|
+
this.listeners.set(name, set);
|
|
7
|
+
return () => set.delete(handler);
|
|
8
|
+
}
|
|
9
|
+
emit(name, ...args) {
|
|
10
|
+
const set = this.listeners.get(name);
|
|
11
|
+
if (!set)
|
|
12
|
+
return;
|
|
13
|
+
for (const h of set) {
|
|
14
|
+
try {
|
|
15
|
+
h(...args);
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
// never let a listener break the loop
|
|
19
|
+
console.error(`[events] listener for ${String(name)} failed:`, err);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
export async function* streamOpenAi(opts) {
|
|
2
|
+
const { baseUrl, apiKey, model, messages, tools, temperature, maxTokens, signal, reasoning } = opts;
|
|
3
|
+
const reasoningStyle = opts.reasoningStyle ?? 'object';
|
|
4
|
+
// Build flat message list for OpenAI format
|
|
5
|
+
const flatMsgs = [];
|
|
6
|
+
for (const m of messages) {
|
|
7
|
+
const msg = { role: m.role };
|
|
8
|
+
if (m.role === 'assistant' && m.toolCalls?.length) {
|
|
9
|
+
msg.content = m.content || '';
|
|
10
|
+
msg.tool_calls = m.toolCalls.map((tc) => ({
|
|
11
|
+
id: tc.id,
|
|
12
|
+
type: 'function',
|
|
13
|
+
function: { name: tc.name, arguments: tc.args },
|
|
14
|
+
}));
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
msg.content = m.content;
|
|
18
|
+
}
|
|
19
|
+
flatMsgs.push(msg);
|
|
20
|
+
if (m.toolResults?.length) {
|
|
21
|
+
for (const r of m.toolResults) {
|
|
22
|
+
flatMsgs.push({
|
|
23
|
+
role: 'tool',
|
|
24
|
+
tool_call_id: r.toolCallId,
|
|
25
|
+
name: r.name,
|
|
26
|
+
content: r.content,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const body = {
|
|
32
|
+
model,
|
|
33
|
+
messages: flatMsgs,
|
|
34
|
+
stream: true,
|
|
35
|
+
stream_options: { include_usage: true },
|
|
36
|
+
};
|
|
37
|
+
if (tools?.length)
|
|
38
|
+
body.tools = tools;
|
|
39
|
+
if (temperature != null)
|
|
40
|
+
body.temperature = temperature;
|
|
41
|
+
if (maxTokens != null)
|
|
42
|
+
body.max_tokens = maxTokens;
|
|
43
|
+
if (reasoning)
|
|
44
|
+
appendReasoning(body, model, reasoning, reasoningStyle);
|
|
45
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
46
|
+
// Auth-less gateways (e.g. a self-hosted Orbit X without ORBITX_TOKEN set)
|
|
47
|
+
// accept requests with no key; only send Authorization when one exists.
|
|
48
|
+
if (apiKey)
|
|
49
|
+
headers.Authorization = `Bearer ${apiKey}`;
|
|
50
|
+
const res = await fetch(`${baseUrl}/chat/completions`, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers,
|
|
53
|
+
body: JSON.stringify(body),
|
|
54
|
+
signal,
|
|
55
|
+
});
|
|
56
|
+
if (!res.ok) {
|
|
57
|
+
const text = await res.text().catch(() => 'unknown error');
|
|
58
|
+
throw new Error(friendlyHttpError(res.status, text, String(body.model ?? model)));
|
|
59
|
+
}
|
|
60
|
+
const reader = res.body?.getReader();
|
|
61
|
+
if (!reader)
|
|
62
|
+
throw new Error('No readable body from LLM');
|
|
63
|
+
const decoder = new TextDecoder();
|
|
64
|
+
let buffer = '';
|
|
65
|
+
const toolAcc = new Map();
|
|
66
|
+
try {
|
|
67
|
+
while (true) {
|
|
68
|
+
const { done, value } = await reader.read();
|
|
69
|
+
if (done)
|
|
70
|
+
break;
|
|
71
|
+
buffer += decoder.decode(value, { stream: true });
|
|
72
|
+
// Process complete lines (each data: line is one SSE event from OpenAI/Groq/OpenRouter)
|
|
73
|
+
let nl;
|
|
74
|
+
while ((nl = buffer.indexOf('\n')) >= 0) {
|
|
75
|
+
const raw = buffer.slice(0, nl);
|
|
76
|
+
buffer = buffer.slice(nl + 1);
|
|
77
|
+
if (!raw.startsWith('data:'))
|
|
78
|
+
continue;
|
|
79
|
+
const data = raw.slice(5).trim();
|
|
80
|
+
if (!data)
|
|
81
|
+
continue;
|
|
82
|
+
if (data === '[DONE]') {
|
|
83
|
+
if (toolAcc.size > 0) {
|
|
84
|
+
for (const [, tc] of toolAcc) {
|
|
85
|
+
yield { type: 'tool_call_start', id: tc.id, name: tc.name };
|
|
86
|
+
yield { type: 'tool_call_args', id: tc.id, args: tc.args };
|
|
87
|
+
yield { type: 'tool_call_end', id: tc.id, name: tc.name, args: tc.args };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
yield { type: 'done' };
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const parsed = jsonSafe(data);
|
|
94
|
+
if (!parsed)
|
|
95
|
+
continue;
|
|
96
|
+
const choices = (parsed.choices ?? []);
|
|
97
|
+
const choice = choices[0];
|
|
98
|
+
const delta = choice?.delta ?? {};
|
|
99
|
+
// Reasoning / thinking
|
|
100
|
+
const reasoning = delta.reasoning_content ?? delta.reasoning ?? delta.thinking;
|
|
101
|
+
if (typeof reasoning === 'string' && reasoning.length > 0) {
|
|
102
|
+
yield { type: 'reasoning', text: reasoning };
|
|
103
|
+
}
|
|
104
|
+
// Content token
|
|
105
|
+
const content = typeof delta.content === 'string' ? delta.content : '';
|
|
106
|
+
if (content.length > 0)
|
|
107
|
+
yield { type: 'token', text: content };
|
|
108
|
+
// Tool calls
|
|
109
|
+
if (Array.isArray(delta.tool_calls)) {
|
|
110
|
+
for (const tc of delta.tool_calls) {
|
|
111
|
+
const idx = typeof tc.index === 'number' ? tc.index : 0;
|
|
112
|
+
const acc = toolAcc.get(idx) ?? { id: '', name: '', args: '' };
|
|
113
|
+
if (tc.id)
|
|
114
|
+
acc.id = tc.id;
|
|
115
|
+
if (tc.function?.name)
|
|
116
|
+
acc.name = tc.function.name;
|
|
117
|
+
if (typeof tc.function?.arguments === 'string')
|
|
118
|
+
acc.args += tc.function.arguments;
|
|
119
|
+
toolAcc.set(idx, acc);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Finish
|
|
123
|
+
const reason = choice?.finish_reason;
|
|
124
|
+
if (reason === 'tool_calls' || reason === 'stop') {
|
|
125
|
+
// Surface tool calls whenever they were accumulated, regardless of
|
|
126
|
+
// the finish reason (some gateways send 'stop' after tool deltas).
|
|
127
|
+
if (toolAcc.size > 0) {
|
|
128
|
+
for (const [, tc] of toolAcc) {
|
|
129
|
+
yield { type: 'tool_call_start', id: tc.id, name: tc.name };
|
|
130
|
+
yield { type: 'tool_call_args', id: tc.id, args: tc.args };
|
|
131
|
+
yield { type: 'tool_call_end', id: tc.id, name: tc.name, args: tc.args };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const usage = parsed.usage;
|
|
135
|
+
yield { type: 'done', usage: usage ? {
|
|
136
|
+
inputTokens: usage.prompt_tokens ?? 0,
|
|
137
|
+
outputTokens: usage.completion_tokens ?? 0,
|
|
138
|
+
} : undefined };
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
reader.releaseLock();
|
|
146
|
+
}
|
|
147
|
+
yield { type: 'done' };
|
|
148
|
+
}
|
|
149
|
+
function jsonSafe(s) {
|
|
150
|
+
try {
|
|
151
|
+
return JSON.parse(s);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function appendReasoning(body, model, reasoning, style) {
|
|
158
|
+
if (style === 'effort') {
|
|
159
|
+
// Groq: top-level `reasoning_effort`. `none` is valid for qwen3 family.
|
|
160
|
+
const effort = reasoning.enabled === false ? 'none' : (reasoning.effort ?? 'medium');
|
|
161
|
+
body.reasoning_effort = groqEffortValue(model, effort);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
// OpenRouter / Orbit X style: `reasoning: { effort }`.
|
|
165
|
+
if (reasoning.enabled === false) {
|
|
166
|
+
body.reasoning = { enabled: false };
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
body.reasoning = { effort: reasoning.effort ?? 'medium' };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/** qwen3-family on Groq only accepts none/default; map our levels to them. */
|
|
173
|
+
function groqEffortValue(model, effort) {
|
|
174
|
+
const isQwen = model.includes('qwen3') || model.startsWith('qwen/qwen3');
|
|
175
|
+
if (isQwen)
|
|
176
|
+
return effort === 'none' ? 'none' : 'default';
|
|
177
|
+
return effort;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Surface upstream HTTP failures in a way the user can act on. Orbit X's
|
|
181
|
+
* public backend returns `503 {"error":{"message":"no provider keys
|
|
182
|
+
* configured on this service","type":"not_configured"}}` when no upstream
|
|
183
|
+
* keys are set — turn that into an explicit, actionable message instead of
|
|
184
|
+
* a generic "route failed".
|
|
185
|
+
*/
|
|
186
|
+
function friendlyHttpError(status, bodyText, model) {
|
|
187
|
+
let message = '';
|
|
188
|
+
try {
|
|
189
|
+
const parsed = JSON.parse(bodyText);
|
|
190
|
+
message = parsed.error?.message ?? '';
|
|
191
|
+
}
|
|
192
|
+
catch { /* non-JSON body */ }
|
|
193
|
+
if (status === 503 && message.toLowerCase().includes('no provider keys configured')) {
|
|
194
|
+
return 'Orbit X: the backend service has no upstream provider keys configured (503 not_configured). ' +
|
|
195
|
+
'Contact the service owner or configure keys on the Orbit X server — the client is fine. ' +
|
|
196
|
+
`Model "${model}" could not be reached.`;
|
|
197
|
+
}
|
|
198
|
+
if ((status === 401 || status === 403) && message) {
|
|
199
|
+
return `Provider rejected the API key (HTTP ${status}): ${message.slice(0, 300)}`;
|
|
200
|
+
}
|
|
201
|
+
if (status === 404) {
|
|
202
|
+
return `Provider returned HTTP 404 — is the model id "${model}" valid? ${message.slice(0, 200)}`;
|
|
203
|
+
}
|
|
204
|
+
if (message)
|
|
205
|
+
return `LLM HTTP ${status}: ${message.slice(0, 300)}`;
|
|
206
|
+
return `LLM HTTP ${status}: ${bodyText.slice(0, 300)}`;
|
|
207
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { getProviderSecrets } from './secrets.js';
|
|
2
|
+
import { createOpenAiProvider } from './providers/openai-compat.js';
|
|
3
|
+
import { createGeminiProvider } from './providers/gemini.js';
|
|
4
|
+
import { PROVIDER_CATALOGS, ORBITX_SERVE, modelsOf } from './models.js';
|
|
5
|
+
export { PROVIDER_CATALOGS as PROVIDER_SPECS, ORBITX_SERVE, modelsOf };
|
|
6
|
+
const ENDPOINTS = {
|
|
7
|
+
groq: 'https://api.groq.com/openai/v1',
|
|
8
|
+
openrouter: 'https://openrouter.ai/api/v1',
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Build providers available in the current environment.
|
|
12
|
+
* Provider keys come ONLY from env or the git-ignored keys.json — never config.
|
|
13
|
+
* Orbit X's token is read through the same secrets layer so it never lands in
|
|
14
|
+
* config.json (the wizard persists it via `writeKeys('orbitx', …)`). The Orbit X
|
|
15
|
+
* provider is always built in orbitx mode; a token is optional — open gateways
|
|
16
|
+
* (no ORBITX_TOKEN set server-side) accept requests without one.
|
|
17
|
+
*/
|
|
18
|
+
export function buildProviders(config) {
|
|
19
|
+
const providers = {};
|
|
20
|
+
// Orbit X token: secrets layer first, legacy config.token as a back-compat fallback.
|
|
21
|
+
const orbitxSec = getProviderSecrets('orbitx');
|
|
22
|
+
const orbitxKey = (orbitxSec?.keys[0] ?? '') ||
|
|
23
|
+
config.orbitx.token ||
|
|
24
|
+
process.env.ORBITX_TOKEN ||
|
|
25
|
+
process.env.ORBITX_API_KEY ||
|
|
26
|
+
'';
|
|
27
|
+
if (config.provider === 'orbitx') {
|
|
28
|
+
providers.orbitx = createOpenAiProvider({
|
|
29
|
+
id: 'orbitx',
|
|
30
|
+
baseUrl: `${config.orbitx.url}/v1`,
|
|
31
|
+
apiKey: orbitxKey,
|
|
32
|
+
models: modelsOf(PROVIDER_CATALOGS.find((p) => p.id === 'orbitx')),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
for (const [id, baseUrl] of Object.entries(ENDPOINTS)) {
|
|
36
|
+
const sec = getProviderSecrets(id);
|
|
37
|
+
if (!sec || sec.keys.length === 0 || sec.keys[0] === undefined)
|
|
38
|
+
continue;
|
|
39
|
+
const spec = PROVIDER_CATALOGS.find((p) => p.id === id);
|
|
40
|
+
providers[id] = createOpenAiProvider({
|
|
41
|
+
id,
|
|
42
|
+
baseUrl,
|
|
43
|
+
apiKey: sec.keys[0],
|
|
44
|
+
models: spec ? modelsOf(spec) : [],
|
|
45
|
+
reasoningStyle: id === 'groq' ? 'effort' : 'object',
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const geminiSec = getProviderSecrets('gemini');
|
|
49
|
+
if (geminiSec && geminiSec.keys[0]) {
|
|
50
|
+
providers.gemini = createGeminiProvider(geminiSec.keys[0]);
|
|
51
|
+
}
|
|
52
|
+
return providers;
|
|
53
|
+
}
|
|
54
|
+
/** Candidate model ids (provider-prefixed) for the router. */
|
|
55
|
+
export async function resolveCandidates(config, providers) {
|
|
56
|
+
const candidates = [];
|
|
57
|
+
// Orbit X mode → list the backend's routable models (primary + fallbacks
|
|
58
|
+
// first, then the curated serve table) so the TUI's /model picker has real
|
|
59
|
+
// choices; the backend does its own internal auto-routing + failover.
|
|
60
|
+
if (config.provider === 'orbitx' && providers.orbitx) {
|
|
61
|
+
const pushBare = (id) => {
|
|
62
|
+
if (!id)
|
|
63
|
+
return;
|
|
64
|
+
const bare = id.split('/').slice(1).join('/') || 'auto';
|
|
65
|
+
const full = `orbitx/${bare}`;
|
|
66
|
+
if (!candidates.includes(full))
|
|
67
|
+
candidates.push(full);
|
|
68
|
+
};
|
|
69
|
+
pushBare(config.model.primary);
|
|
70
|
+
for (const fb of config.model.fallback)
|
|
71
|
+
pushBare(fb);
|
|
72
|
+
for (const bare of ORBITX_SERVE)
|
|
73
|
+
pushBare(bare);
|
|
74
|
+
return candidates;
|
|
75
|
+
}
|
|
76
|
+
const push = (id) => {
|
|
77
|
+
const provId = id.split('/')[0];
|
|
78
|
+
if (providers[provId] && !candidates.includes(id))
|
|
79
|
+
candidates.push(id);
|
|
80
|
+
};
|
|
81
|
+
push(config.model.primary);
|
|
82
|
+
for (const fb of config.model.fallback)
|
|
83
|
+
push(fb);
|
|
84
|
+
// If nothing configured resolved, fall back to any available provider models.
|
|
85
|
+
if (candidates.length === 0) {
|
|
86
|
+
for (const name of Object.keys(providers)) {
|
|
87
|
+
const prov = providers[name];
|
|
88
|
+
for (const m of await prov.listModels())
|
|
89
|
+
push(m);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return candidates;
|
|
93
|
+
}
|