brito-cli 0.6.4 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,20 +10,25 @@ import "../tools/delegate.js";
10
10
  import "../tools/plan.js";
11
11
  import "../tools/skills.js";
12
12
  import { Agent } from "../agent/loop.js";
13
- import { ClaudeSession, hasClaudeCli } from "../agent/claude-cli.js";
13
+ import { ClaudeSession, hasClaudeCli, historyForFallback } from "../agent/claude-cli.js";
14
14
  import { startApprovalServer } from "../agent/approval.js";
15
15
  import { loadProjectAgents } from "../agent/agents.js";
16
16
  import { buildSystemPrompt, graphStatsLine } from "../agent/prompt.js";
17
- import { GraphStore } from "../graph/store.js";
17
+ import { openGraphAfterClaude } from "../graph/store.js";
18
18
  import { indexProject } from "../graph/indexer.js";
19
- import { DEFAULT_MODEL, initialEffort, initialModel, isEffort, MODEL_ALIASES } from "../model.js";
19
+ import { initialEffort, initialModel, isEffort, loadProfiles, MODEL_ALIASES } from "../model.js";
20
+ import { FALLBACK_PROFILE, NO_ANTHROPIC, fallbackStatus, openEngine } from "../engine.js";
21
+ import { activateFallback, chooseModel, cliShouldFallBack, fallbackWarning, newFallbackState, renderFallback, retryPrompt } from "../provider/fallback.js";
22
+ import { contextWindowFor, resolveModel } from "../provider/resolve.js";
20
23
  import { PLAN_NOTE, perms } from "../permissions.js";
21
24
  import { skillsIndex } from "../skills/store.js";
22
25
  /**
23
26
  * Backend do app desktop, controlado por JSON em linhas.
24
27
  * stdin → {t:"send",text} {t:"abort"} {t:"approve",id,allow} {t:"config",model?,effort?,mode?} {t:"reset"} {t:"quit"}
25
28
  * stdout → linhas "@@{json}" (qualquer outra linha, como logs perdidos, a janela ignora).
26
- * Motor: com ANTHROPIC_API_KEY usa a API direta (como o `brito` no terminal); sem ela, o `claude` CLI (assinatura).
29
+ * Motor: perfil de endpoint (ex.: Qwen) ou ANTHROPIC_API_KEY usam o loop próprio (como o `brito` no terminal); sem
30
+ * nada disso, o `claude` CLI (assinatura). Se o Claude cai por conta/capacidade (ou nem há conta), segue no qwen.
31
+ * `/fallback` enviado como texto responde o estado do fallback sem passar pelo modelo.
27
32
  */
28
33
  const MARK = "@@";
29
34
  const emit = (ev) => process.stdout.write(MARK + JSON.stringify(ev) + "\n");
@@ -39,7 +44,7 @@ const sink = {
39
44
  warn: (msg) => emit({ t: "warn", msg }),
40
45
  error: (msg) => emit({ t: "error", msg }),
41
46
  };
42
- const CTX_WINDOW = Number(process.env.BRITO_CONTEXT_WINDOW ?? 200_000);
47
+ const apiLabel = (m) => (resolveModel(m).kind === "openai" ? "endpoint" : "API Anthropic");
43
48
  export async function serveDesktop(root, resumeId) {
44
49
  const pending = new Map();
45
50
  const ask = (text, opts) => perms.mode === "bypass" && !opts?.force ? Promise.resolve(true) : new Promise((resolve) => {
@@ -49,21 +54,26 @@ export async function serveDesktop(root, resumeId) {
49
54
  });
50
55
  // Na pasta pessoal (projeto "Home") indexar tudo seria enorme e inútil: sem índice ali.
51
56
  const isHome = path.resolve(root) === path.resolve(os.homedir());
52
- const useApi = !!process.env.ANTHROPIC_API_KEY;
53
- if (!useApi && !hasClaudeCli()) {
54
- emit({ t: "error", msg: "Sem acesso ao modelo: defina ANTHROPIC_API_KEY (no seu shell) ou instale o Claude Code e rode `claude` → /login." });
57
+ for (const e of loadProfiles().errors)
58
+ emit({ t: "warn", msg: e });
59
+ const startModel = initialModel();
60
+ const opening = openEngine(startModel, process.env, hasClaudeCli);
61
+ if (opening.kind === "error") {
62
+ const msg = resolveModel(startModel).kind === "openai" ? opening.message : opening.message.replace(/^Defina ANTHROPIC_API_KEY/, "Sem acesso ao modelo: defina ANTHROPIC_API_KEY (no seu shell)");
63
+ emit({ t: "error", msg });
55
64
  process.exit(1);
56
65
  }
57
66
  loadProjectAgents(root);
58
67
  let engine;
59
68
  let syms = 0, notes = 0;
60
69
  let closeApproval = () => { };
61
- if (useApi) {
62
- // API direta: o grafo fica aberto neste processo (sem servidor MCP), como no `brito` do terminal.
70
+ /** Loop próprio (API direta ou endpoint): o grafo fica aberto neste processo (sem servidor MCP), como no `brito` do terminal. */
71
+ const buildApiEngine = async (model, o = {}) => {
63
72
  // No Home o banco vai para ~/.brito/home-graph, para não criar .brito dentro da pasta pessoal.
64
73
  const graphRoot = isHome ? path.join(os.homedir(), ".brito", "home-graph") : root;
65
74
  fs.mkdirSync(graphRoot, { recursive: true });
66
- const graph = await GraphStore.open(graphRoot);
75
+ // Na troca, o servidor MCP do claude pode ainda estar soltando o banco.
76
+ const graph = await openGraphAfterClaude(graphRoot);
67
77
  const sync = async () => { if (!isHome)
68
78
  syms = (await indexProject(root, graph)).symbols; };
69
79
  emit({ t: "info", msg: "sincronizando grafo…" });
@@ -77,16 +87,41 @@ export async function serveDesktop(root, resumeId) {
77
87
  notes = Number((await graph.run("MATCH (s:Note) RETURN count(s) AS n"))[0].n);
78
88
  }
79
89
  catch { /* sem notas */ }
80
- const ctx = { root, graph, ask, sync, dirty: false, loadedSkills: new Set() };
81
- const agent = new Agent(() => buildSystemPrompt(root, graphStatsLine(syms, notes, "now"), skillsIndex(root)) + (perms.mode === "plan" ? PLAN_NOTE : ""), ctx, { model: initialModel(), sink });
90
+ const ctx = { root, graph, ask, sync, dirty: false, loadedSkills: o.loadedSkills ?? new Set(), model: () => agent.model };
91
+ const fallback = o.fallback ?? newFallbackState(model);
92
+ const agent = new Agent(() => buildSystemPrompt(root, graphStatsLine(syms, notes, "now"), skillsIndex(root)) + (perms.mode === "plan" ? PLAN_NOTE : ""), ctx, { model, sink, fallback });
93
+ if (o.messages)
94
+ agent.messages = o.messages;
95
+ if (o.effort)
96
+ agent.effort = o.effort;
82
97
  let ac = null;
83
98
  const id = randomUUID();
84
- if (resumeId)
85
- emit({ t: "info", msg: "No modo API o histórico da conversa não é retomado ao reabrir; a tela mostra o que já foi dito, mas o modelo recomeça." });
86
- engine = {
87
- kind: "api", sessionId: id, effort: "",
99
+ return {
100
+ kind: "api", effort: "", fallback,
101
+ // Depois da troca o id segue o do claude: a janela o guarda para retomar a conversa ao reabrir.
102
+ get sessionId() { return o.back?.engine.sessionId ?? id; },
88
103
  get model() { return agent.model; }, set model(m) { agent.model = m; },
89
- setModel: (m) => { agent.model = m; },
104
+ engineLabel: o.engineLabel ?? apiLabel,
105
+ setModel: (m) => {
106
+ const target = m || initialModel();
107
+ // Sem ANTHROPIC_API_KEY o Claude não roda neste loop: volta ao claude CLI de onde veio, ou segue no qwen.
108
+ if (resolveModel(target).kind === "anthropic" && !process.env.ANTHROPIC_API_KEY?.trim()) {
109
+ if (o.back) {
110
+ engine = o.back.engine;
111
+ engine.setModel(m);
112
+ if (!m)
113
+ chooseModel(fallback, target); // com m vazio o claude não escolhe (o modelo dele vira o placeholder)
114
+ o.back.release(graph.close().catch(() => { }));
115
+ o.back.handoff(agent.unsentTurns.splice(0), agent.model);
116
+ sink.info("de volta ao claude CLI: o que foi feito no qwen segue no próximo pedido");
117
+ }
118
+ else if (m)
119
+ sink.warn(`o ${m} precisa de ANTHROPIC_API_KEY (ou do claude CLI); seguindo no ${agent.model}`);
120
+ return;
121
+ }
122
+ agent.model = target;
123
+ chooseModel(fallback, target);
124
+ },
90
125
  async send(text) {
91
126
  ac = new AbortController();
92
127
  sink.turnStart();
@@ -104,10 +139,30 @@ export async function serveDesktop(root, resumeId) {
104
139
  }
105
140
  },
106
141
  abort: () => ac?.abort(),
107
- reset: () => { agent.messages = []; agent.contextTokens = 0; agent.recentTurns = []; agent.lastTurn = null; },
108
- stats: () => ({ usage: agent.usage, contextTokens: agent.contextTokens, contextWindow: CTX_WINDOW, costUsd: 0 }),
142
+ reset: () => {
143
+ agent.messages = [];
144
+ agent.contextTokens = 0;
145
+ agent.recentTurns = [];
146
+ agent.unsentTurns = [];
147
+ agent.lastTurn = null;
148
+ o.back?.engine.reset(); // senão a volta ao claude retomaria (--resume) a conversa limpa
149
+ },
150
+ stats: () => ({ usage: agent.usage, contextTokens: agent.contextTokens, contextWindow: contextWindowFor(agent.model), costUsd: o.back?.costUsd() ?? 0 }),
109
151
  close: async () => { await graph.close(); },
110
152
  };
153
+ };
154
+ if (opening.kind !== "claude") {
155
+ if (resumeId)
156
+ emit({ t: "info", msg: "No modo API o histórico da conversa não é retomado ao reabrir; a tela mostra o que já foi dito, mas o modelo recomeça." });
157
+ if (opening.kind === "fallback") {
158
+ // Claude sem conta nenhuma, com o fallback pronto: a sessão já nasce no qwen (com aviso, nunca em silêncio).
159
+ emit({ t: "warn", msg: `⚠ ${NO_ANTHROPIC} (sem ANTHROPIC_API_KEY e sem o claude CLI) — abrindo no ${opening.label}. Envie /fallback para detalhes.` });
160
+ const fallback = newFallbackState(startModel);
161
+ activateFallback(fallback, NO_ANTHROPIC);
162
+ engine = await buildApiEngine(FALLBACK_PROFILE, { fallback, engineLabel: () => NO_ANTHROPIC });
163
+ }
164
+ else
165
+ engine = await buildApiEngine(startModel);
111
166
  }
112
167
  else {
113
168
  const approval = startApprovalServer({ ask });
@@ -115,7 +170,7 @@ export async function serveDesktop(root, resumeId) {
115
170
  // O banco aceita um único processo com escrita: indexa, lê as stats e solta o banco para o servidor MCP.
116
171
  if (!isHome)
117
172
  try {
118
- const graph = await GraphStore.open(root);
173
+ const graph = await openGraphAfterClaude(root);
119
174
  emit({ t: "info", msg: "sincronizando grafo…" });
120
175
  await indexProject(root, graph);
121
176
  syms = Number((await graph.run("MATCH (s:Symbol) RETURN count(s) AS n"))[0].n);
@@ -133,22 +188,60 @@ export async function serveDesktop(root, resumeId) {
133
188
  });
134
189
  if (resumeId)
135
190
  session.resume(resumeId);
191
+ let resumed = !!resumeId; // até o primeiro reset
136
192
  session.effort = initialEffort();
137
- engine = {
138
- kind: "claude",
193
+ const fallback = newFallbackState(session.requestedModel || session.model);
194
+ /** Fechamento do grafo do modo API na volta para cá (ver `back.release`). */
195
+ let graphReleased = Promise.resolve();
196
+ const claude = {
197
+ kind: "claude", fallback,
139
198
  get sessionId() { return session.sessionId; },
140
199
  get model() { return session.model; },
141
200
  get effort() { return session.effort; }, set effort(e) { session.effort = e; },
142
- setModel: (m) => { session.requestedModel = m; session.model = m || "claude (assinatura)"; },
143
- send: async (text) => { await session.send(text); },
201
+ engineLabel: () => "claude CLI · assinatura",
202
+ setModel: (m) => { session.requestedModel = m; session.model = m || "claude (assinatura)"; if (m)
203
+ chooseModel(fallback, m); },
204
+ send: async (text) => {
205
+ await graphReleased;
206
+ if (await session.send(text))
207
+ return;
208
+ // Caiu por conta/capacidade: monta o loop próprio no qwen com a conversa do claude e refaz o pedido nele.
209
+ const status = fallbackStatus(fallback.primary, process.env);
210
+ const f = session.lastFailure;
211
+ if (!f || !status.ready || !cliShouldFallBack(f, fallback, status))
212
+ return;
213
+ sink.warn(fallbackWarning(f.kind, f.message, status.label));
214
+ // `resume` só reaproveita o id: o que veio antes da reabertura está na sessão do claude, não em `allTurns`.
215
+ if (resumed)
216
+ sink.warn(`a conversa anterior à reabertura ficou no claude CLI: o ${status.label} herda só o que foi dito desde então`);
217
+ let api;
218
+ try {
219
+ api = await buildApiEngine(status.name, {
220
+ fallback, engineLabel: () => "claude CLI · assinatura", messages: historyForFallback(session.allTurns),
221
+ effort: session.effort, loadedSkills: session.loadedSkills, back: {
222
+ engine: claude, costUsd: () => session.costUsd, handoff: (turns, label) => session.handoff(turns, label),
223
+ release: (closing) => { graphReleased = closing; },
224
+ },
225
+ });
226
+ }
227
+ catch (e) {
228
+ sink.error(`não consegui abrir o ${status.label}: ${e?.message ?? e}`);
229
+ return;
230
+ }
231
+ activateFallback(fallback, `${f.kind} (${f.message})`);
232
+ engine = api;
233
+ ready();
234
+ await api.send(retryPrompt(text, resumed));
235
+ },
144
236
  abort: () => session.abort(),
145
- reset: () => session.reset(),
237
+ reset: () => { session.reset(); resumed = false; },
146
238
  stats: () => ({ usage: session.usage, contextTokens: session.contextTokens, contextWindow: session.contextWindow, costUsd: session.costUsd }),
147
239
  close: async () => { },
148
240
  };
241
+ engine = claude;
149
242
  }
150
243
  let busy = false;
151
- const ready = () => emit({ t: "ready", engine: engine.kind, sessionId: engine.sessionId, model: engine.model, effort: engine.effort, mode: perms.mode, symbols: syms, notes, root });
244
+ const ready = () => emit({ t: "ready", engine: engine.kind, sessionId: engine.sessionId, model: engine.model, effort: engine.effort, mode: perms.mode, symbols: syms, notes, root, fallback: engine.fallback.active });
152
245
  ready();
153
246
  const rl = readline.createInterface({ input: process.stdin });
154
247
  const quit = async () => { closeApproval(); try {
@@ -168,13 +261,23 @@ export async function serveDesktop(root, resumeId) {
168
261
  case "send": {
169
262
  if (busy)
170
263
  return emit({ t: "warn", msg: "já existe um turno em andamento" });
264
+ if (String(m.text ?? "").trim() === "/fallback") {
265
+ const fb = engine.fallback;
266
+ const primary = fb.active ? fb.primary : engine.model;
267
+ const lines = renderFallback(fb, engine.model, engine.engineLabel(primary), fallbackStatus(primary, process.env));
268
+ sink.turnStart();
269
+ sink.text("```\n" + lines.join("\n") + "\n```");
270
+ sink.textEnd();
271
+ sink.turnEnd(true);
272
+ return;
273
+ }
171
274
  busy = true;
172
275
  try {
173
276
  await engine.send(String(m.text ?? ""));
174
277
  }
175
278
  finally {
176
279
  busy = false;
177
- emit({ t: "usage", ...engine.stats(), model: engine.model, sessionId: engine.sessionId });
280
+ emit({ t: "usage", ...engine.stats(), model: engine.model, sessionId: engine.sessionId, fallback: engine.fallback.active });
178
281
  }
179
282
  break;
180
283
  }
@@ -186,10 +289,8 @@ export async function serveDesktop(root, resumeId) {
186
289
  pending.delete(m.id);
187
290
  break;
188
291
  case "config": {
189
- if (typeof m.model === "string" && m.model)
190
- engine.setModel(MODEL_ALIASES[m.model] ?? m.model);
191
- else if (m.model === "")
192
- engine.setModel(engine.kind === "api" ? DEFAULT_MODEL : "");
292
+ if (typeof m.model === "string")
293
+ engine.setModel(m.model ? MODEL_ALIASES[m.model] ?? m.model : ""); // vazio = modelo/perfil salvo
193
294
  if (m.effort !== undefined)
194
295
  engine.effort = isEffort(m.effort) ? m.effort : "";
195
296
  if (["manual", "auto", "plan", "bypass"].includes(m.mode))
package/dist/engine.js ADDED
@@ -0,0 +1,79 @@
1
+ import { MODEL_ALIASES, loadProfiles } from "./model.js";
2
+ import { missingKeyMessage } from "./provider/openai-compat.js";
3
+ import { resolveModel } from "./provider/resolve.js";
4
+ /** O fallback é sempre este perfil de `providers` (não configurável). */
5
+ export const FALLBACK_PROFILE = "qwen";
6
+ /**
7
+ * Pronto só se o perfil existe, é válido, tem a chave no ambiente e o principal não é ele mesmo. Relê o config a
8
+ * cada chamada, como os perfis.
9
+ */
10
+ export function fallbackStatus(primary, env) {
11
+ const { profiles, errors, rejected } = loadProfiles();
12
+ const p = profiles[FALLBACK_PROFILE];
13
+ const label = p?.label;
14
+ if (primary.trim().toLowerCase() === FALLBACK_PROFILE)
15
+ return { ready: false, reason: "o modelo principal já é o qwen", label };
16
+ if (rejected.includes(FALLBACK_PROFILE)) {
17
+ const why = errors.find((e) => e.toLowerCase().includes(`"${FALLBACK_PROFILE}"`)) ?? "";
18
+ return { ready: false, reason: `o perfil "${FALLBACK_PROFILE}" em ~/.brito/config.json está inválido (${why})` };
19
+ }
20
+ if (!p)
21
+ return { ready: false, reason: `sem perfil "${FALLBACK_PROFILE}" em providers no ~/.brito/config.json` };
22
+ if (!env[p.apiKeyEnv]?.trim())
23
+ return { ready: false, reason: missingKeyMessage(p), label };
24
+ return { ready: true, name: FALLBACK_PROFILE, label: p.label };
25
+ }
26
+ /** Modelo padrão do subagente `explore` no Claude: rápido e barato. */
27
+ export const EXPLORE_DEFAULT = MODEL_ALIASES.haiku;
28
+ /**
29
+ * Qual motor atende o modelo: perfil de endpoint → loop próprio (modo API), sem olhar a Anthropic nem o claude CLI.
30
+ * Claude → a regra de sempre: com ANTHROPIC_API_KEY, API direta; sem ela, o `claude` oficial (assinatura).
31
+ */
32
+ export function pickEngine(model, env, hasCli) {
33
+ const r = resolveModel(model);
34
+ const name = model.trim().toLowerCase();
35
+ if (r.kind === "anthropic" && loadProfiles().rejected.includes(name)) {
36
+ const why = loadProfiles().errors.find((e) => e.toLowerCase().includes(`"${name}"`)) ?? "";
37
+ return { kind: "error", message: `o perfil "${name}" em ~/.brito/config.json está inválido (${why}); corrija antes de usar` };
38
+ }
39
+ if (r.kind === "openai")
40
+ return env[r.profile.apiKeyEnv]?.trim() ? { kind: "api" } : { kind: "error", message: missingKeyMessage(r.profile) };
41
+ if (env.ANTHROPIC_API_KEY)
42
+ return { kind: "api" };
43
+ if (hasCli())
44
+ return { kind: "claude" };
45
+ return { kind: "error", message: "Defina ANTHROPIC_API_KEY ou instale o Claude Code e rode `claude` → /login." };
46
+ }
47
+ /** Motivo do fallback quando a sessão já abre no qwen. */
48
+ export const NO_ANTHROPIC = "sem conta da Anthropic";
49
+ /**
50
+ * Motor da abertura: o de `pickEngine`, salvo quando um Claude não tem conta (sem ANTHROPIC_API_KEY e sem o `claude`)
51
+ * e o fallback está pronto — aí abre direto no qwen. Sem fallback pronto, o erro de sempre leva o motivo dele.
52
+ * Perfil inválido nunca vira fallback: continua o erro de perfil.
53
+ */
54
+ export function openEngine(model, env, hasCli) {
55
+ const e = pickEngine(model, env, hasCli);
56
+ if (e.kind !== "error" || resolveModel(model).kind !== "anthropic" || loadProfiles().rejected.includes(model.trim().toLowerCase()))
57
+ return e;
58
+ const s = fallbackStatus(model, env);
59
+ return s.ready ? { kind: "fallback", label: s.label } : { kind: "error", message: `${e.message}\nFallback (qwen) indisponível: ${s.reason}.` };
60
+ }
61
+ /**
62
+ * Modelo de um subagente. BRITO_EXPLORE_MODEL manda no `explore`. Com o agente principal num endpoint, subagente
63
+ * que pediria um Claude fica no modelo do principal (senão a conta da Anthropic voltaria a ser usada em silêncio).
64
+ */
65
+ export function subagentModel(def, parent, env) {
66
+ if (def.name === "explore" && env.BRITO_EXPLORE_MODEL)
67
+ return env.BRITO_EXPLORE_MODEL;
68
+ const wanted = def.model ?? (def.name === "explore" ? EXPLORE_DEFAULT : undefined);
69
+ if (resolveModel(parent).kind === "openai" && (!wanted || resolveModel(wanted).kind === "anthropic"))
70
+ return parent;
71
+ return wanted ?? parent;
72
+ }
73
+ /** Modelo da reflexão/consolidação de skills: BRITO_LEARN_MODEL, salvo se ele levaria a conta à Anthropic com um perfil ativo. */
74
+ export function learnerModel(live, env) {
75
+ const wanted = env.BRITO_LEARN_MODEL;
76
+ if (!wanted)
77
+ return live;
78
+ return resolveModel(live).kind === "openai" && resolveModel(wanted).kind === "anthropic" ? live : wanted;
79
+ }
@@ -209,3 +209,18 @@ export class GraphStore {
209
209
  await this.release();
210
210
  }
211
211
  }
212
+ /**
213
+ * Abre o grafo no processo do Brito depois de um turno do claude: o servidor MCP dele solta o lock ao morrer, mas pode
214
+ * levar um instante além do `close` do filho. Uma tentativa extra curta cobre isso.
215
+ */
216
+ export async function openGraphAfterClaude(root) {
217
+ try {
218
+ return await GraphStore.open(root);
219
+ }
220
+ catch (e) {
221
+ if (!(e instanceof GraphLockedError))
222
+ throw e;
223
+ await sleep(500);
224
+ return GraphStore.open(root);
225
+ }
226
+ }
package/dist/model.js CHANGED
@@ -132,5 +132,51 @@ export function parseModelArgs(raw) {
132
132
  const scope = flags.has("once") ? "once" : flags.has("global") && !flags.has("session") ? "global" : "session";
133
133
  return { model: model ? resolveModelAlias(model) : "", scope, errors };
134
134
  }
135
- /** Texto de ajuda mostrado por `/model` sem argumentos. */
136
- export const MODEL_HELP = "uso: /model <nome|alias> [--global|--session|--once] (aliases: " + Object.keys(MODEL_ALIASES).join(", ") + ")";
135
+ /** Texto de ajuda mostrado por `/model` sem argumentos (perfis de endpoint entram junto dos aliases). */
136
+ export function modelHelp() {
137
+ const profiles = Object.keys(loadProfiles().profiles);
138
+ return "uso: /model <nome|alias> [--global|--session|--once] (aliases: " + Object.keys(MODEL_ALIASES).join(", ") + ")" +
139
+ (profiles.length ? ` (endpoints: ${profiles.join(", ")})` : "");
140
+ }
141
+ const isText = (v) => typeof v === "string" && v.trim() !== "";
142
+ /**
143
+ * Perfis de endpoint compatível com OpenAI (`providers` no config). Relido a cada chamada: é barato e o config muda
144
+ * durante a sessão. Perfil inválido não derruba o Brito: fica de fora e o motivo vai em `errors`.
145
+ */
146
+ export function loadProfiles() {
147
+ let raw;
148
+ try {
149
+ raw = JSON.parse(readFileSync(configPath(), "utf8"))?.providers;
150
+ }
151
+ catch {
152
+ return { profiles: {}, errors: [], rejected: [] };
153
+ }
154
+ const profiles = {};
155
+ const errors = [];
156
+ /** nomes de perfis inválidos: escolher um deles é erro, nunca cair no Claude */
157
+ const rejected = [];
158
+ if (!raw || typeof raw !== "object")
159
+ return { profiles, errors, rejected };
160
+ for (const [key, v] of Object.entries(raw)) {
161
+ const name = key.trim().toLowerCase();
162
+ const missing = ["baseURL", "model", "apiKeyEnv"].filter((f) => !isText(v?.[f]));
163
+ if (v?.type !== "openai") {
164
+ errors.push(`perfil "${key}" ignorado: type precisa ser "openai"`);
165
+ rejected.push(name);
166
+ continue;
167
+ }
168
+ if (missing.length) {
169
+ errors.push(`perfil "${key}" ignorado: faltam ${missing.join(", ")}`);
170
+ rejected.push(name);
171
+ continue;
172
+ }
173
+ profiles[name] = {
174
+ name, type: "openai", label: isText(v.label) ? v.label.trim() : name,
175
+ baseURL: v.baseURL.trim(), model: v.model.trim(), apiKeyEnv: v.apiKeyEnv.trim(),
176
+ contextWindow: Number(v.contextWindow) > 0 ? Number(v.contextWindow) : 128_000,
177
+ vision: v.vision === true,
178
+ thinkingToggle: v.thinkingToggle === true,
179
+ };
180
+ }
181
+ return { profiles, errors, rejected };
182
+ }
@@ -0,0 +1,31 @@
1
+ import Anthropic from "@anthropic-ai/sdk";
2
+ import { resolveModel } from "./resolve.js";
3
+ /** API da Anthropic, exatamente como o Brito sempre chamou (system em cache, streaming quando há quem leia o texto). */
4
+ export class AnthropicProvider {
5
+ kind = "anthropic";
6
+ client = new Anthropic();
7
+ async send(req, hooks = {}) {
8
+ const params = {
9
+ model: resolveModel(req.model).wireModel, // aliases (sonnet, opus…) viram o id real
10
+ max_tokens: req.maxTokens,
11
+ system: [{ type: "text", text: req.system, cache_control: { type: "ephemeral" } }],
12
+ ...(req.tools?.length ? { tools: req.tools } : {}),
13
+ messages: req.messages,
14
+ };
15
+ let msg;
16
+ if (hooks.onText) {
17
+ const stream = this.client.messages.stream(params, { signal: hooks.signal });
18
+ stream.on("text", (t) => hooks.onText(t));
19
+ msg = await stream.finalMessage();
20
+ }
21
+ else
22
+ msg = await this.client.messages.create(params, { signal: hooks.signal });
23
+ const u = msg.usage;
24
+ const content = msg.content.flatMap((b) => b.type === "text" ? [{ type: "text", text: b.text }] : b.type === "tool_use" ? [{ type: "tool_use", id: b.id, name: b.name, input: b.input }] : []);
25
+ const stopReason = msg.stop_reason === "end_turn" || msg.stop_reason === "tool_use" || msg.stop_reason === "max_tokens" ? msg.stop_reason : "other";
26
+ return {
27
+ content, stopReason,
28
+ usage: { input: u.input_tokens + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0), output: u.output_tokens, cacheRead: u.cache_read_input_tokens ?? 0 },
29
+ };
30
+ }
31
+ }
@@ -0,0 +1,147 @@
1
+ import Anthropic from "@anthropic-ai/sdk";
2
+ import { FALLBACK_PROFILE } from "../engine.js";
3
+ export const triggersFallback = (k) => k !== "other";
4
+ /**
5
+ * Classifica um erro da API da Anthropic (já depois dos retries do SDK). Na dúvida, `other`: melhor não trocar do que
6
+ * esconder um bug atrás do qwen.
7
+ */
8
+ export function classifyAnthropicError(e) {
9
+ if (e instanceof Anthropic.APIUserAbortError || e?.name === "AbortError")
10
+ return "other";
11
+ if (e instanceof Anthropic.APIConnectionError)
12
+ return "network"; // inclui APIConnectionTimeoutError
13
+ if (!(e instanceof Anthropic.APIError))
14
+ return "other";
15
+ const status = e.status ?? 0;
16
+ const type = e.type ?? "";
17
+ if (status === 429 || type === "rate_limit_error")
18
+ return "rate_limit";
19
+ if (status === 402 || type === "billing_error" || (status === 400 && /credit balance is too low/i.test(e.message)))
20
+ return "quota";
21
+ if (status === 401 || status === 403 || type === "authentication_error" || type === "permission_error")
22
+ return "auth";
23
+ if (type === "timeout_error")
24
+ return "network";
25
+ if (status === 529 || status >= 500 || type === "overloaded_error")
26
+ return "overloaded";
27
+ return "other";
28
+ }
29
+ /** Acumula os sinais de falha de um evento do stream-json. Só a thread principal: subagente que falha não derruba o turno. */
30
+ export function collectClaudeFailure(f, ev) {
31
+ if (ev?.parent_tool_use_id)
32
+ return;
33
+ if (ev?.type === "assistant" && typeof ev.error === "string") {
34
+ f.error = ev.error;
35
+ if (typeof ev.api_error_status === "number")
36
+ f.status = ev.api_error_status;
37
+ }
38
+ else if (ev?.type === "result" && ev.is_error) {
39
+ f.result = String(ev.result ?? "");
40
+ if (typeof ev.api_error_status === "number")
41
+ f.status = ev.api_error_status;
42
+ }
43
+ }
44
+ const CLI_AUTH_ERRORS = new Set(["authentication_failed", "oauth_org_not_allowed", "account_on_hold", "verification_required", "cloud_credential_error"]);
45
+ /** Erros do CLI que são de conteúdo/bug: não troca, mesmo que o texto fale em "limit". */
46
+ const CLI_OTHER_ERRORS = new Set(["invalid_request", "model_not_found", "max_output_tokens"]);
47
+ /** "limit reached" que não é de conta: contexto, orçamento de agentes, concorrência. */
48
+ const NOT_ACCOUNT_LIMIT = /context limit|budget limit|subagent limit|turn budget|export limit|device limit/i;
49
+ const SUBSCRIPTION_LIMIT = /usage limit reached|limit reached|hit your (?:\w+ )?limit|resets \d|out of usage credits|spend limit|credit balance/i;
50
+ /**
51
+ * Classifica a falha de um turno do `claude` CLI: primeiro pelos campos estruturados do stream-json, depois pelo texto
52
+ * (as frases mudam entre versões). Na dúvida, `other`. Interrupção nunca chega aqui (a sessão filtra antes).
53
+ */
54
+ export function classifyClaudeCliFailure(f) {
55
+ if (f.spawnError)
56
+ return /ENOENT|EACCES|not found/i.test(f.spawnError) ? "auth" : "other";
57
+ const text = `${f.result ?? ""}\n${f.stderr}`;
58
+ const accountLimit = SUBSCRIPTION_LIMIT.test(text) && !NOT_ACCOUNT_LIMIT.test(text);
59
+ const e = f.error ?? "";
60
+ if (CLI_OTHER_ERRORS.has(e))
61
+ return "other";
62
+ if (CLI_AUTH_ERRORS.has(e))
63
+ return "auth";
64
+ if (e === "billing_error")
65
+ return "quota";
66
+ if (e === "rate_limit")
67
+ return accountLimit ? "quota" : "rate_limit";
68
+ if (e === "overloaded" || e === "server_error")
69
+ return "overloaded";
70
+ const s = f.status ?? 0;
71
+ if (s === 429)
72
+ return accountLimit ? "quota" : "rate_limit";
73
+ if (s === 402)
74
+ return "quota";
75
+ if (s === 401 || s === 403)
76
+ return "auth";
77
+ if (s === 529 || s >= 500)
78
+ return "overloaded";
79
+ if (NOT_ACCOUNT_LIMIT.test(text))
80
+ return "other";
81
+ if (accountLimit)
82
+ return "quota";
83
+ if (/rate.?limit/i.test(text))
84
+ return "rate_limit";
85
+ if (/invalid api key|\/login|not logged in|oauth token|failed to authenticate|log in again/i.test(text))
86
+ return "auth";
87
+ if (/overloaded|\b529\b/i.test(text))
88
+ return "overloaded";
89
+ return "other";
90
+ }
91
+ /** Primeira linha do erro, curta, para o aviso e o `/fallback`. */
92
+ export const shortError = (e, max = 120) => {
93
+ const line = String(e?.message ?? e).split("\n")[0].trim();
94
+ return line.length > max ? line.slice(0, max - 1) + "…" : line;
95
+ };
96
+ export const newFallbackState = (primary) => ({ primary, active: false });
97
+ /** `/model <x>` é escolha explícita: vira o principal e desfaz o fallback (inclusive `/model qwen`). */
98
+ export function chooseModel(s, model) {
99
+ s.primary = model;
100
+ s.active = false;
101
+ s.reason = undefined;
102
+ s.at = undefined;
103
+ }
104
+ /** A troca automática aconteceu. */
105
+ export function activateFallback(s, reason, at = Date.now()) {
106
+ s.active = true;
107
+ s.reason = reason;
108
+ s.at = at;
109
+ }
110
+ /** Aviso impresso na troca (modo API e claude CLI): a troca nunca é silenciosa. */
111
+ export const fallbackWarning = (kind, why, label) => `⚠ Claude falhou (${kind}: ${why}) — seguindo com ${label}. /fallback para detalhes, /model <claude> para voltar.`;
112
+ /**
113
+ * O turno do claude CLI que falhou deve ser refeito no qwen? Só com falha de conta/capacidade, fallback pronto e
114
+ * ainda não usado na sessão. Interrupção não deixa `failure` (a sessão filtra), mas o `aborted` fica de garantia.
115
+ */
116
+ export function cliShouldFallBack(failure, state, status, aborted = false) {
117
+ return !!failure && triggersFallback(failure.kind) && status.ready && !state.active && !aborted;
118
+ }
119
+ /** Nota que precede o pedido refeito no qwen depois da troca de motor (spec §5.5). */
120
+ export const RETRY_NOTE = "[O modelo anterior (Claude) falhou no meio deste pedido. O que já foi feito está no histórico acima; continue de onde parou sem refazer ações já concluídas.]";
121
+ /** Sessão retomada (app reaberto): o que veio antes da reabertura ficou no claude CLI e não está no histórico. */
122
+ export const RESUMED_NOTE = "[A conversa anterior à reabertura da sessão não foi herdada: só o que veio depois dela está no histórico. Se faltar contexto, pergunte ao usuário em vez de supor.]";
123
+ export const retryPrompt = (text, resumed = false) => `${RETRY_NOTE}${resumed ? `\n${RESUMED_NOTE}` : ""}\n\n${text}`;
124
+ /**
125
+ * Modelo a devolver depois de um turno com `/model --once`. O `--once` zera o fallback, então `active` no fim do turno
126
+ * quer dizer que a troca aconteceu durante ele: aí o `restore` é descartado (voltaria ao Claude que acabou de cair).
127
+ */
128
+ export const onceRestore = (restore, fallbackActive) => (fallbackActive ? null : restore);
129
+ const hhmm = (ms) => { const d = new Date(ms); return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; };
130
+ /**
131
+ * Linhas do `/fallback`. Principal = o modelo vivo, salvo com o fallback ativo (aí é o principal original e o qwen
132
+ * aparece em uso). `status` vem de `fallbackStatus(state.primary, env)`; `engineLabel` é o motor do principal.
133
+ */
134
+ export function renderFallback(state, liveModel, engineLabel, status) {
135
+ const primary = state.active ? state.primary : liveModel;
136
+ const name = status.label ?? "—";
137
+ const self = !state.active && primary.trim().toLowerCase() === FALLBACK_PROFILE;
138
+ const fb = state.active ? `${name} qwen · ~/.brito/config.json ● em uso`
139
+ : status.ready ? `${name} qwen · ~/.brito/config.json ✓ pronto`
140
+ : self ? `${name} — o modelo principal já é o qwen`
141
+ : `${name} ✗ ${status.reason}`;
142
+ const estado = state.active ? `ATIVO desde ${hhmm(state.at ?? Date.now())} — Claude falhou: ${state.reason ?? "?"}. /model <claude> para voltar`
143
+ : status.ready ? "inativo — troca automática se o Claude falhar por limite, crédito, auth ou sobrecarga"
144
+ : self ? "não se aplica"
145
+ : "indisponível — sem troca automática nesta sessão";
146
+ return [` principal ${primary} (${engineLabel})`, ` fallback ${fb}`, ` estado ${estado}`];
147
+ }
@@ -0,0 +1,27 @@
1
+ import { AnthropicProvider } from "./anthropic.js";
2
+ import { OpenAICompatProvider } from "./openai-compat.js";
3
+ import { resolveModel } from "./resolve.js";
4
+ let factory = null;
5
+ /** Só para testes: troca a criação de provedores. `null` volta ao normal. */
6
+ export function setProviderFactory(f) { factory = f; }
7
+ let anthropic;
8
+ const openai = new Map();
9
+ /**
10
+ * Provedor de um modelo pelo nome que o usuário usa. O cliente da Anthropic só nasce quando um modelo Claude é usado:
11
+ * com um perfil de endpoint escolhido, nada da Anthropic é instanciado.
12
+ */
13
+ export function providerFor(name) {
14
+ const r = resolveModel(name);
15
+ if (factory)
16
+ return factory(r);
17
+ if (r.kind === "anthropic")
18
+ return (anthropic ??= new AnthropicProvider());
19
+ // O config pode mudar na sessão: refaz o provedor quando o perfil muda.
20
+ const key = JSON.stringify(r.profile);
21
+ const hit = openai.get(r.name);
22
+ if (hit?.key === key)
23
+ return hit.provider;
24
+ const provider = new OpenAICompatProvider(r.profile);
25
+ openai.set(r.name, { key, provider });
26
+ return provider;
27
+ }