@100xprompt/brain 0.1.2 → 0.1.4
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/README.md +59 -2
- package/package.json +1 -1
- package/src/cli.ts +74 -9
- package/src/dashboard/cli.ts +25 -9
- package/src/dashboard/report.ts +84 -0
- package/src/dashboard/send.ts +20 -4
- package/src/dashboard/state.ts +67 -5
- package/src/fsutil.ts +19 -1
- package/src/gateway/common.ts +56 -4
- package/src/gateway/connect.ts +256 -100
- package/src/gateway/connectors/aider.ts +3 -5
- package/src/gateway/connectors/cline-sdk.ts +2 -5
- package/src/gateway/connectors/continue.ts +5 -6
- package/src/gateway/connectors/crush.ts +2 -2
- package/src/gateway/connectors/factory-droid.ts +6 -1
- package/src/gateway/connectors/goose.ts +4 -4
- package/src/gateway/connectors/hermes.ts +7 -3
- package/src/gateway/connectors/kimi-code.ts +2 -2
- package/src/gateway/connectors/letta-code.ts +4 -7
- package/src/gateway/connectors/openhands.ts +9 -4
- package/src/gateway/connectors/qwen-code.ts +16 -2
- package/src/gateway/connectors/vscode-copilot.ts +4 -6
- package/src/gateway/signin.ts +44 -12
- package/src/hooks.ts +20 -9
- package/src/lock.ts +269 -0
- package/src/memory/audit.ts +6 -4
- package/src/memory/cli.ts +6 -0
- package/src/memory/externalize.ts +17 -4
- package/src/memory/mcp-install.ts +63 -7
- package/src/memory/mcp-server.ts +4 -0
- package/src/memory/prepare.ts +12 -4
- package/src/memory/sync.ts +3 -0
- package/src/settings/account.ts +336 -0
- package/src/settings/apply.ts +306 -0
- package/src/settings/catalog.ts +476 -0
- package/src/settings/cli.ts +439 -0
- package/src/settings/env.ts +101 -0
- package/src/settings/pair.ts +83 -0
- package/src/settings/store.ts +216 -0
- package/src/settings/values.ts +80 -0
package/src/gateway/common.ts
CHANGED
|
@@ -12,15 +12,66 @@ export const DEFAULT_ROUTE = "flash"
|
|
|
12
12
|
export const DEFAULT_MODEL = "gemini-3.8-flash"
|
|
13
13
|
export const EDIT_HEADER = "x-100x-context-edit"
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* `--model auto`: the gateway's auto-routing (the router's docs/routing-contract.md, "The /auto route"). Its route serves
|
|
17
|
+
* only this model id, and this id only on its route: per kind of work (everyday asks, code, reasoning) the gateway serves
|
|
18
|
+
* a cheaper model where the company's own test in the Savings app proved it no worse, and 100xprompt Pro everywhere else.
|
|
19
|
+
*/
|
|
20
|
+
export const AUTO_ROUTE = "auto"
|
|
21
|
+
export const AUTO_MODEL = "100xprompt-auto"
|
|
22
|
+
|
|
15
23
|
/**
|
|
16
24
|
* What 100xprompt calls the models brain may connect a tool to, as the 100xprompt CLI shows them: tools are given the
|
|
17
25
|
* gateway's model id (that is what the gateway serves), and everything brain prints uses the product name.
|
|
18
26
|
*/
|
|
19
|
-
export const MODEL_NAMES: Record<string, string> = {
|
|
20
|
-
|
|
21
|
-
|
|
27
|
+
export const MODEL_NAMES: Record<string, string> = {
|
|
28
|
+
"gemini-3.8-flash": "100xprompt Pro",
|
|
29
|
+
"gemini-3.7-flash": "100xprompt Flash",
|
|
30
|
+
"gemini-3.6-flash": "100xprompt Flash",
|
|
31
|
+
"gemini-3.5-flash": "100xprompt Flash",
|
|
32
|
+
[AUTO_MODEL]: "100xprompt Auto",
|
|
33
|
+
}
|
|
34
|
+
/** `--model pro` / `--model flash` / `--model auto`: the product names, turned into the gateway's model ids. */
|
|
35
|
+
export const MODEL_ALIASES: Record<string, string> = { pro: "gemini-3.8-flash", flash: "gemini-3.7-flash", auto: AUTO_MODEL }
|
|
22
36
|
export const modelName = (id: string) => MODEL_NAMES[id] ?? id
|
|
23
37
|
export const modelId = (given: string) => MODEL_ALIASES[given.trim().toLowerCase()] ?? given.trim()
|
|
38
|
+
/**
|
|
39
|
+
* Whether a connect uses auto-routing. No tool knows this id: a connector whose tool sizes its context window (or picks
|
|
40
|
+
* its capabilities) from the model id it is given tells it, for auto, what every model auto may serve takes (limitsFor).
|
|
41
|
+
*/
|
|
42
|
+
export const isAuto = (s: Pick<GatewaySettings, "model">) => s.model === AUTO_MODEL
|
|
43
|
+
/** What the Gemini Flash models behind 100xprompt Pro and Flash take: 1,048,576 tokens in, 65,536 out. */
|
|
44
|
+
export const GEMINI_LIMITS = { context: 1_048_576, output: 65_536 } as const
|
|
45
|
+
/**
|
|
46
|
+
* What every model auto-routing may serve takes (the contract's table: the Gemini Flash models and DeepSeek V4 Flash): the
|
|
47
|
+
* smaller window, DeepSeek V4 Flash's 1,000,000 tokens, and Gemini's 65,536 out (DeepSeek allows more). Both take text and
|
|
48
|
+
* images; audio, video and PDF only Gemini, so a tool told what it may send gets images only.
|
|
49
|
+
*/
|
|
50
|
+
export const AUTO_LIMITS = { context: 1_000_000, output: 65_536 } as const
|
|
51
|
+
/** The context window and output limit a connector tells its tool, for the model it connects. */
|
|
52
|
+
export const limitsFor = (s: Pick<GatewaySettings, "model">) => (isAuto(s) ? AUTO_LIMITS : GEMINI_LIMITS)
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The route and model a connect uses, from the flags given (undefined: not given) and the settings stored by the last
|
|
56
|
+
* connect. The auto model and the auto route go together: `--model auto` picks the auto route (a different --route with
|
|
57
|
+
* it is refused), `--route auto` picks the auto model, and another model or route after an auto connect goes back to the
|
|
58
|
+
* default route (flash) or model (100xprompt Pro).
|
|
59
|
+
*/
|
|
60
|
+
export function routeAndModel(given: { route?: string; model?: string }, stored: { route?: string; model?: string } = {}): { route: string; model: string } | { why: string } {
|
|
61
|
+
const model = given.model !== undefined ? modelId(given.model) : undefined
|
|
62
|
+
const route = given.route
|
|
63
|
+
if (model === AUTO_MODEL) {
|
|
64
|
+
if (route !== undefined && route !== AUTO_ROUTE) return { why: `--model auto uses the gateway's ${AUTO_ROUTE} route, so it cannot be combined with --route ${route}; leave out --route` }
|
|
65
|
+
return { route: AUTO_ROUTE, model }
|
|
66
|
+
}
|
|
67
|
+
if (route === AUTO_ROUTE) {
|
|
68
|
+
if (model !== undefined) return { why: `the ${AUTO_ROUTE} route chooses the model itself, so it cannot be combined with --model ${given.model!.trim()}; use --model auto` }
|
|
69
|
+
return { route: AUTO_ROUTE, model: AUTO_MODEL }
|
|
70
|
+
}
|
|
71
|
+
const wasAuto = stored.model === AUTO_MODEL || stored.route === AUTO_ROUTE
|
|
72
|
+
if (route === undefined && model === undefined) return wasAuto ? { route: AUTO_ROUTE, model: AUTO_MODEL } : { route: stored.route ?? DEFAULT_ROUTE, model: stored.model ?? DEFAULT_MODEL }
|
|
73
|
+
return { route: route ?? (wasAuto ? DEFAULT_ROUTE : (stored.route ?? DEFAULT_ROUTE)), model: model ?? (wasAuto ? DEFAULT_MODEL : (stored.model ?? DEFAULT_MODEL)) }
|
|
74
|
+
}
|
|
24
75
|
|
|
25
76
|
export interface GatewaySettings {
|
|
26
77
|
url: string
|
|
@@ -46,7 +97,8 @@ export function readKey(home: string): string {
|
|
|
46
97
|
}
|
|
47
98
|
|
|
48
99
|
/**
|
|
49
|
-
* The gateway route's base address, e.g. https://proxy.100xprompt.com/flash
|
|
100
|
+
* The gateway route's base address, e.g. https://proxy.100xprompt.com/flash (or /auto, which takes the same paths). Under
|
|
101
|
+
* it the gateway serves, for every model:
|
|
50
102
|
* /v1/chat/completions OpenAI Chat Completions (Authorization: Bearer <key>)
|
|
51
103
|
* /v1/responses OpenAI Responses (Authorization: Bearer <key>)
|
|
52
104
|
* /v1/messages Anthropic Messages (x-api-key: <key>, or Authorization: Bearer)
|
package/src/gateway/connect.ts
CHANGED
|
@@ -15,9 +15,10 @@
|
|
|
15
15
|
// stores the key that comes back in <home>/.brain/gateway.key (0600). For scripts and CI, --key-env VAR takes it from an
|
|
16
16
|
// environment variable instead; --from-cli reuses the 100xprompt CLI's sign-in. It is never printed. Tools read it at run time where they can (Claude Code's apiKeyHelper,
|
|
17
17
|
// OpenCode's {file:...}); Codex and Gemini CLI can only take it in their own config, which is then made private (0600).
|
|
18
|
-
import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync,
|
|
18
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmdirSync, rmSync, statSync } from "node:fs"
|
|
19
19
|
import { basename, dirname, join, resolve } from "node:path"
|
|
20
20
|
import {
|
|
21
|
+
AUTO_ROUTE,
|
|
21
22
|
brainDir,
|
|
22
23
|
type Codec,
|
|
23
24
|
type Connector,
|
|
@@ -34,9 +35,9 @@ import {
|
|
|
34
35
|
type Json,
|
|
35
36
|
keyPath,
|
|
36
37
|
MODEL_NAMES,
|
|
37
|
-
modelId,
|
|
38
38
|
modelName,
|
|
39
39
|
readKey,
|
|
40
|
+
routeAndModel,
|
|
40
41
|
routeUrl,
|
|
41
42
|
setAt,
|
|
42
43
|
shellQuote,
|
|
@@ -44,8 +45,14 @@ import {
|
|
|
44
45
|
} from "./common.ts"
|
|
45
46
|
import { EXTRA_CONNECTORS } from "./connectors/index.ts"
|
|
46
47
|
import { askHidden, browserSignIn, type SignInDeps } from "./signin.ts"
|
|
48
|
+
import { writeAtomic } from "../fsutil.ts"
|
|
49
|
+
import { withBrainLockSync } from "../lock.ts"
|
|
50
|
+
import { toolGatewayPrefs } from "../settings/values.ts"
|
|
51
|
+
import { readMachineToken } from "../dashboard/state.ts"
|
|
47
52
|
|
|
48
53
|
export {
|
|
54
|
+
AUTO_MODEL,
|
|
55
|
+
AUTO_ROUTE,
|
|
49
56
|
DEFAULT_MODEL,
|
|
50
57
|
DEFAULT_ROUTE,
|
|
51
58
|
DEFAULT_URL,
|
|
@@ -76,7 +83,17 @@ interface State {
|
|
|
76
83
|
keySource?: string
|
|
77
84
|
/** Who signed in (from the sign-in page), shown by status. */
|
|
78
85
|
email?: string
|
|
79
|
-
tools: Record<string,
|
|
86
|
+
tools: Record<string, ToolRecord>
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface ToolRecord {
|
|
90
|
+
at: string
|
|
91
|
+
changes: Change[]
|
|
92
|
+
created?: string[]
|
|
93
|
+
createdDirs?: string[]
|
|
94
|
+
modes?: Record<string, number>
|
|
95
|
+
/** The settings this tool was connected with (older records have none: `settings` above is the last connect's). */
|
|
96
|
+
settings?: GatewaySettings
|
|
80
97
|
}
|
|
81
98
|
|
|
82
99
|
function loadState(home: string): State {
|
|
@@ -95,11 +112,7 @@ function saveState(home: string, s: State) {
|
|
|
95
112
|
|
|
96
113
|
/** Atomic write with mode 0600 (the key and the record of what was changed). */
|
|
97
114
|
function writePrivate(path: string, text: string) {
|
|
98
|
-
|
|
99
|
-
rmSync(tmp, { force: true })
|
|
100
|
-
writeFileSync(tmp, text, { mode: 0o600 })
|
|
101
|
-
chmodSync(tmp, 0o600)
|
|
102
|
-
renameSync(tmp, path)
|
|
115
|
+
writeAtomic(path, text, 0o600)
|
|
103
116
|
}
|
|
104
117
|
|
|
105
118
|
/**
|
|
@@ -122,11 +135,7 @@ function writeToolFile(home: string, tool: string, path: string, text: string, m
|
|
|
122
135
|
keep = statSync(real).mode & 0o777
|
|
123
136
|
} catch {}
|
|
124
137
|
}
|
|
125
|
-
|
|
126
|
-
rmSync(tmp, { force: true })
|
|
127
|
-
writeFileSync(tmp, text, keep === undefined ? {} : { mode: keep })
|
|
128
|
-
if (keep !== undefined) chmodSync(tmp, keep)
|
|
129
|
-
renameSync(tmp, real)
|
|
138
|
+
writeAtomic(real, text, keep)
|
|
130
139
|
}
|
|
131
140
|
|
|
132
141
|
// ---- the key ----------------------------------------------------------------------------------------------------------
|
|
@@ -167,6 +176,10 @@ export function hasKey(home: string): boolean {
|
|
|
167
176
|
* on the old one while status names the new. Returns the tools whose copy was replaced.
|
|
168
177
|
*/
|
|
169
178
|
export function storeKey(home: string, key: string, source: string, email?: string, env: Env = process.env): string[] {
|
|
179
|
+
return withBrainLockSync(home, () => storeKeyLocked(home, key, source, email, env), { what: "storing the gateway key" })
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function storeKeyLocked(home: string, key: string, source: string, email: string | undefined, env: Env): string[] {
|
|
170
183
|
const old = readKey(home)
|
|
171
184
|
mkdirSync(brainDir(home), { recursive: true, mode: 0o700 })
|
|
172
185
|
writePrivate(keyPath(home), `${key}\n`)
|
|
@@ -251,6 +264,10 @@ export async function signIn(home: string, d: SignInDeps, env: Env = process.env
|
|
|
251
264
|
|
|
252
265
|
/** Forgets the key. Tools still connected stop working until the next login, which is said. */
|
|
253
266
|
export function logout(home: string, env: Record<string, string | undefined> = process.env): string[] {
|
|
267
|
+
return withBrainLockSync(home, () => logoutLocked(home, env), { what: "signing out" })
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function logoutLocked(home: string, env: Record<string, string | undefined>): string[] {
|
|
254
271
|
const had = hasKey(home)
|
|
255
272
|
rmSync(keyPath(home), { force: true })
|
|
256
273
|
const s = loadState(home)
|
|
@@ -575,6 +592,8 @@ export async function probeKey(home: string, c: Connector, s: GatewaySettings, f
|
|
|
575
592
|
export interface Result {
|
|
576
593
|
ok: boolean
|
|
577
594
|
lines: string[]
|
|
595
|
+
/** connect: the settings the tool was connected with. */
|
|
596
|
+
settings?: GatewaySettings
|
|
578
597
|
}
|
|
579
598
|
|
|
580
599
|
export function installedHere(c: Connector, home: string, env: Record<string, string | undefined>): boolean {
|
|
@@ -583,103 +602,219 @@ export function installedHere(c: Connector, home: string, env: Record<string, st
|
|
|
583
602
|
return existsSync(f) || (resolve(dirname(f)) !== resolve(home) && existsSync(dirname(f))) || c.probe.some((p) => existsSync(join(home, p)))
|
|
584
603
|
}
|
|
585
604
|
|
|
605
|
+
/** Whether a tool is on the gateway now, read from its files (gateway.json alone can be wrong after a hand edit). */
|
|
606
|
+
export interface Connection {
|
|
607
|
+
/** brain's values are all in the tool's files, some of them, or none (or brain never connected it). */
|
|
608
|
+
state: "connected" | "partial" | "not-connected"
|
|
609
|
+
/** The settings it was connected with; undefined when an older brain connected it and they cannot be told. */
|
|
610
|
+
settings?: GatewaySettings
|
|
611
|
+
/** What no longer holds brain's value, when it is not all there. */
|
|
612
|
+
why?: string
|
|
613
|
+
/** The values brain set that the files no longer hold (JSON), to tell which part was changed. */
|
|
614
|
+
changed?: string[]
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** Every string in a value (and every key of an object in it). */
|
|
618
|
+
const stringsOf = (v: unknown): string[] =>
|
|
619
|
+
typeof v === "string" ? [v] : Array.isArray(v) ? v.flatMap(stringsOf) : v && typeof v === "object" ? Object.entries(v).flatMap(([k, x]) => [k, ...stringsOf(x)]) : []
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* The settings a tool was connected with. Brains before 0.1.4 kept only the last connect's settings (for every tool): those
|
|
623
|
+
* are this tool's only when it was the last one connected. Otherwise they are read back from what connect wrote in the
|
|
624
|
+
* tool's files: the gateway address and route, the model, and whether the context-editing header is there.
|
|
625
|
+
*/
|
|
626
|
+
function settingsOf(st: State, id: string, values: unknown[]): GatewaySettings | undefined {
|
|
627
|
+
const rec = st.tools[id]
|
|
628
|
+
if (!rec) return undefined
|
|
629
|
+
if (rec.settings) return rec.settings
|
|
630
|
+
const newest = Object.entries(st.tools).sort((a, b) => (b[1].at ?? "").localeCompare(a[1].at ?? ""))[0]?.[0]
|
|
631
|
+
if (newest === id && st.settings) return st.settings
|
|
632
|
+
let url: string | undefined
|
|
633
|
+
let route: string | undefined
|
|
634
|
+
let model: string | undefined
|
|
635
|
+
let edit = false
|
|
636
|
+
for (const t of values.flatMap(stringsOf)) {
|
|
637
|
+
if (t.includes(EDIT_HEADER)) edit = true
|
|
638
|
+
model ??= Object.keys(MODEL_NAMES).find((m) => t === m || t.endsWith(`/${m}`))
|
|
639
|
+
const m = /^(https?:\/\/\S+?)\/([a-z0-9-]{1,40})(?:\/v1)?\/?$/.exec(t)
|
|
640
|
+
if (m && !url) {
|
|
641
|
+
url = m[1]
|
|
642
|
+
route = m[2]
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return url && route && model ? { url, route, model, contextEdit: edit } : undefined
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
export function connectionState(home: string, toolId: string, env: Env = process.env): Connection {
|
|
649
|
+
const c = connectorFor(toolId)
|
|
650
|
+
if (!c) return { state: "not-connected" }
|
|
651
|
+
const st = loadState(home)
|
|
652
|
+
const rec = st.tools[c.id]
|
|
653
|
+
if (!rec) return { state: "not-connected" }
|
|
654
|
+
const codecs = codecsOf(c, home, st, env)
|
|
655
|
+
const docs = new Map<string, Doc | { error: string }>()
|
|
656
|
+
let held = 0
|
|
657
|
+
let first: string | undefined
|
|
658
|
+
const changed: string[] = []
|
|
659
|
+
// What connect wrote: its recorded values, and for entries holding the key, what the file has (the key itself is never kept).
|
|
660
|
+
const written: unknown[] = []
|
|
661
|
+
for (const x of rec.changes) {
|
|
662
|
+
if (!docs.has(x.file)) docs.set(x.file, load(x.format, x.file, codecs.get(x.file)))
|
|
663
|
+
const d = docs.get(x.file)!
|
|
664
|
+
const current = "error" in d ? undefined : getKey(d, x.key)
|
|
665
|
+
const secret = x.after === "(contains the key)"
|
|
666
|
+
const ours = !("error" in d) && (secret ? current !== undefined : same(current, x.after))
|
|
667
|
+
written.push(secret ? current : x.after)
|
|
668
|
+
if (ours) held++
|
|
669
|
+
else {
|
|
670
|
+
first ??= `${basename(x.file)}: ${x.key.replace(/\//g, ".")}`
|
|
671
|
+
changed.push(JSON.stringify(x.after))
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
const settings = settingsOf(st, c.id, written)
|
|
675
|
+
const withSettings = settings ? { settings } : {}
|
|
676
|
+
if (held === rec.changes.length) return { state: "connected", ...withSettings }
|
|
677
|
+
if (held === 0) return { state: "not-connected", ...withSettings, why: `brain's settings are no longer in ${c.label}'s files (changed by hand?)`, changed }
|
|
678
|
+
return { state: "partial", ...withSettings, why: `${first} changed since brain connected it`, changed }
|
|
679
|
+
}
|
|
680
|
+
|
|
586
681
|
export async function connect(
|
|
587
682
|
home: string,
|
|
588
683
|
toolId: string,
|
|
589
|
-
o: {
|
|
684
|
+
o: {
|
|
685
|
+
settings?: Partial<GatewaySettings>
|
|
686
|
+
dryRun?: boolean
|
|
687
|
+
probe?: boolean
|
|
688
|
+
env?: Record<string, string | undefined>
|
|
689
|
+
fetchImpl?: typeof fetch
|
|
690
|
+
/** Where a flag leaves the model or context editing open, use the ones saved for this tool with `brain settings`. */
|
|
691
|
+
usePrefs?: boolean
|
|
692
|
+
/** Called after the files are written, still under the brain lock, with the settings used (to record them). */
|
|
693
|
+
onWritten?: (s: GatewaySettings) => void
|
|
694
|
+
} = {},
|
|
590
695
|
): Promise<Result> {
|
|
591
696
|
const env = o.env ?? process.env
|
|
592
697
|
const c = connectorFor(toolId)
|
|
593
698
|
if (!c) return { ok: false, lines: [`unknown tool "${toolId}"; \`brain gateway tools\` lists the ones that can be connected`] }
|
|
594
699
|
if (!hasKey(home) && !o.dryRun) return { ok: false, lines: ["not signed in to the gateway yet: run `brain gateway login`"] }
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
700
|
+
// The settings to connect with, from the flags, then (with usePrefs) what is saved for this tool, then the last connect's.
|
|
701
|
+
// Worked out again inside the lock: another brain may have changed them while the key was checked.
|
|
702
|
+
const resolveSettings = (st: State): GatewaySettings | { why: string } => {
|
|
703
|
+
const given = o.settings ?? {}
|
|
704
|
+
const prefs = o.usePrefs ? toolGatewayPrefs(home, c.id) : {}
|
|
705
|
+
// With --route, a saved model is used only when neither is auto (the auto model and the auto route go together).
|
|
706
|
+
const model = given.model ?? (prefs.model !== undefined && (given.route === undefined || (given.route !== AUTO_ROUTE && prefs.model !== "auto")) ? prefs.model : undefined)
|
|
707
|
+
// --model auto goes with the auto route (and back to flash after it): common.ts routeAndModel.
|
|
708
|
+
const rm = routeAndModel({ route: given.route, model }, st.settings ?? {})
|
|
709
|
+
if ("why" in rm) return rm
|
|
710
|
+
const s: GatewaySettings = { url: given.url ?? st.settings?.url ?? DEFAULT_URL, route: rm.route, model: rm.model, contextEdit: given.contextEdit ?? prefs.contextEdit ?? st.settings?.contextEdit ?? true }
|
|
711
|
+
if (!/^https?:\/\/[^\s]+$/.test(s.url) || (/^http:/.test(s.url) && !/^http:\/\/(127\.0\.0\.1|localhost)(:|\/|$)/.test(s.url))) return { why: "--url must be https:// (http:// only for a gateway on this machine)" }
|
|
712
|
+
if (!/^[a-z0-9-]{1,40}$/.test(s.route)) return { why: "--route must be a gateway route name such as flash or pro" }
|
|
713
|
+
if (!/^[\w.:/-]{1,80}$/.test(s.model)) return { why: "--model must be pro, flash, auto or a plain model name" }
|
|
714
|
+
return s
|
|
715
|
+
}
|
|
716
|
+
const first = resolveSettings(loadState(home))
|
|
717
|
+
if ("why" in first) return { ok: false, lines: [first.why] }
|
|
718
|
+
const checked: GatewaySettings = first
|
|
605
719
|
const main = c.file(home, env)
|
|
606
720
|
if (!installedHere(c, home, env)) return { ok: false, lines: [`${c.label} has not been run on this machine (nothing at ${main}); run it once, then connect it`] }
|
|
607
721
|
let limit: string | undefined
|
|
608
|
-
|
|
609
|
-
|
|
722
|
+
const probed = o.probe !== false && !o.dryRun
|
|
723
|
+
if (probed) {
|
|
724
|
+
const p = await probeKey(home, c, checked, o.fetchImpl)
|
|
610
725
|
limit = p.limit
|
|
611
|
-
if (p.verdict === "rejected") return { ok: false, lines: [`the gateway at ${routeUrl(
|
|
612
|
-
if (p.verdict === "unreachable") return { ok: false, lines: [`could not reach ${routeUrl(
|
|
613
|
-
}
|
|
614
|
-
// Read and
|
|
615
|
-
|
|
616
|
-
const
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
if (
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
726
|
+
if (p.verdict === "rejected") return { ok: false, lines: [`the gateway at ${routeUrl(checked)} refused the key the way ${c.label} sends it (${c.authHeader}); nothing was changed`] }
|
|
727
|
+
if (p.verdict === "unreachable") return { ok: false, lines: [`could not reach ${routeUrl(checked)}; nothing was changed (pass --no-probe to connect anyway)`] }
|
|
728
|
+
}
|
|
729
|
+
// Read, change and write under the brain lock (the network check above is done before taking it), from the state
|
|
730
|
+
// as it is now: another brain may have changed it while the key was checked.
|
|
731
|
+
const write = (): Result => {
|
|
732
|
+
const st = loadState(home)
|
|
733
|
+
// Signed out while the key was checked: nothing to put in the tool's files.
|
|
734
|
+
if (!o.dryRun && !hasKey(home)) return { ok: false, lines: ["not signed in to the gateway any more (signed out meanwhile); nothing was changed"] }
|
|
735
|
+
const fresh = resolveSettings(st)
|
|
736
|
+
if ("why" in fresh) return { ok: false, lines: [fresh.why] }
|
|
737
|
+
if (probed && routeUrl(fresh) !== routeUrl(checked)) return { ok: false, lines: [`the gateway address changed to ${routeUrl(fresh)} while the key was checked at ${routeUrl(checked)}; nothing was changed: run it again`] }
|
|
738
|
+
const s = fresh
|
|
739
|
+
// Read and check every file first: nothing is written unless all of them can be changed.
|
|
740
|
+
const edits = c.edits(home, s, env)
|
|
741
|
+
const docs: Array<{ e: FileEdit; d: Doc }> = []
|
|
742
|
+
for (const e of edits) {
|
|
743
|
+
const d = load(e.format, e.path, e.codec)
|
|
744
|
+
if ("error" in d) return { ok: false, lines: [`${e.path}: ${d.error}; nothing was changed`] }
|
|
745
|
+
docs.push({ e, d })
|
|
746
|
+
}
|
|
747
|
+
const prior = st.tools[c.id]?.changes ?? []
|
|
748
|
+
const changes: Change[] = []
|
|
749
|
+
const shown: string[] = []
|
|
750
|
+
try {
|
|
751
|
+
for (const { e, d } of docs) {
|
|
752
|
+
for (const [key, after] of Object.entries(e.values)) {
|
|
753
|
+
const current = getKey(d, key)
|
|
754
|
+
// A reconnect keeps the value from before the FIRST connect, so disconnect still restores the person's own.
|
|
755
|
+
const was = prior.find((x) => x.file === e.path && x.key === key)
|
|
756
|
+
const secret = isSecret(e, key)
|
|
757
|
+
changes.push({ file: e.path, format: e.format, key, before: was ? was.before : current, after: secret ? "(contains the key)" : after })
|
|
758
|
+
if (!same(current, after)) setKey(d, key, after)
|
|
759
|
+
shown.push(` ${basename(e.path)}: ${key.replace(/\//g, ".")} = ${secret ? "(gateway settings and key)" : shownValue(after)}`)
|
|
760
|
+
}
|
|
761
|
+
// A key an earlier connect set that this one no longer wants (--no-context-edit after a connect with it): put it back now.
|
|
762
|
+
for (const x of prior) {
|
|
763
|
+
if (x.file !== e.path || x.key in e.values) continue
|
|
764
|
+
const current = getKey(d, x.key)
|
|
765
|
+
if (x.after === "(contains the key)" ? current !== undefined : same(current, x.after)) setKey(d, x.key, x.before)
|
|
766
|
+
}
|
|
641
767
|
}
|
|
768
|
+
} catch (err) {
|
|
769
|
+
// A file format the connector's codec could not change after all: nothing has been written yet.
|
|
770
|
+
return { ok: false, lines: [`could not change ${c.label}'s settings (${(err as Error).message.slice(0, 200)}); nothing was changed`] }
|
|
642
771
|
}
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
772
|
+
const named = MODEL_NAMES[s.model] ? [`note: models are shown by their 100xprompt names; the files get the gateway's id for ${modelName(s.model)}`] : []
|
|
773
|
+
if (o.dryRun) return { ok: true, lines: [`would change:`, ...shown, ...named, ...c.notes.map((n) => `note: ${n}`)] }
|
|
774
|
+
const created = new Set(st.tools[c.id]?.created ?? [])
|
|
775
|
+
for (const { e } of docs) if (!existsSync(e.path)) created.add(e.path)
|
|
776
|
+
// Folders connect has to create (Goose's custom_providers/), so disconnect can take them away again when they are empty.
|
|
777
|
+
const createdDirs = new Set(st.tools[c.id]?.createdDirs ?? [])
|
|
778
|
+
for (const { e } of docs) for (let dir = dirname(e.path); !existsSync(dir) && dir.startsWith(`${resolve(home)}/`); dir = dirname(dir)) createdDirs.add(dir)
|
|
779
|
+
// A file that is made private (it gets the key) goes back to the mode it had when disconnect gives it back.
|
|
780
|
+
const modes: Record<string, number> = { ...(st.tools[c.id]?.modes ?? {}) }
|
|
781
|
+
for (const { e } of docs) {
|
|
782
|
+
if (!e.holdsKey || !existsSync(e.path) || modes[e.path] !== undefined) continue
|
|
783
|
+
try {
|
|
784
|
+
modes[e.path] = statSync(e.path).mode & 0o777
|
|
785
|
+
} catch {}
|
|
786
|
+
}
|
|
787
|
+
const first = !st.tools[c.id]
|
|
788
|
+
for (const { e, d } of docs) writeToolFile(home, c.id, e.path, render(d), e.holdsKey ? 0o600 : undefined, first)
|
|
789
|
+
st.settings = s
|
|
790
|
+
st.tools[c.id] = { at: new Date().toISOString(), changes, ...(created.size ? { created: [...created] } : {}), ...(createdDirs.size ? { createdDirs: [...createdDirs] } : {}), ...(Object.keys(modes).length ? { modes } : {}), settings: s }
|
|
791
|
+
saveState(home, st)
|
|
658
792
|
try {
|
|
659
|
-
|
|
793
|
+
o.onWritten?.(s)
|
|
660
794
|
} catch {}
|
|
795
|
+
return {
|
|
796
|
+
ok: true,
|
|
797
|
+
settings: s,
|
|
798
|
+
lines: [
|
|
799
|
+
`connected ${c.label} to ${routeUrl(s)} (${modelName(s.model)}${s.contextEdit ? ", context editing on" : ""})`,
|
|
800
|
+
...(limit ? [`warning: the gateway accepts the key, but this account cannot use it right now: ${limit}`, `warning: ${c.label} will get that error on every request until the limit resets (or brain gateway disconnect --tool ${c.id} puts it back).`] : []),
|
|
801
|
+
"changed:",
|
|
802
|
+
...shown,
|
|
803
|
+
...c.notes.map((n) => `note: ${n}`),
|
|
804
|
+
`undo: brain gateway disconnect --tool ${c.id}`,
|
|
805
|
+
],
|
|
806
|
+
}
|
|
661
807
|
}
|
|
662
|
-
|
|
663
|
-
for (const { e, d } of docs) writeToolFile(home, c.id, e.path, render(d), e.holdsKey ? 0o600 : undefined, first)
|
|
664
|
-
st.settings = s
|
|
665
|
-
st.tools[c.id] = { at: new Date().toISOString(), changes, ...(created.size ? { created: [...created] } : {}), ...(createdDirs.size ? { createdDirs: [...createdDirs] } : {}), ...(Object.keys(modes).length ? { modes } : {}) }
|
|
666
|
-
saveState(home, st)
|
|
667
|
-
return {
|
|
668
|
-
ok: true,
|
|
669
|
-
lines: [
|
|
670
|
-
`connected ${c.label} to ${routeUrl(s)} (${modelName(s.model)}${s.contextEdit ? ", context editing on" : ""})`,
|
|
671
|
-
...(limit ? [`warning: the gateway accepts the key, but this account cannot use it right now: ${limit}`, `warning: ${c.label} will get that error on every request until the limit resets (or brain gateway disconnect --tool ${c.id} puts it back).`] : []),
|
|
672
|
-
"changed:",
|
|
673
|
-
...shown,
|
|
674
|
-
...c.notes.map((n) => `note: ${n}`),
|
|
675
|
-
`undo: brain gateway disconnect --tool ${c.id}`,
|
|
676
|
-
],
|
|
677
|
-
}
|
|
808
|
+
return o.dryRun ? write() : withBrainLockSync(home, write, { what: `connecting ${c.label}` })
|
|
678
809
|
}
|
|
679
810
|
|
|
680
811
|
export function disconnect(home: string, toolId: string, o: { dryRun?: boolean; env?: Env } = {}): Result {
|
|
681
812
|
const c = connectorFor(toolId)
|
|
682
813
|
if (!c) return { ok: false, lines: [`unknown tool "${toolId}"`] }
|
|
814
|
+
return o.dryRun ? disconnectLocked(home, c, o) : withBrainLockSync(home, () => disconnectLocked(home, c, o), { what: `disconnecting ${c.label}` })
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function disconnectLocked(home: string, c: Connector, o: { dryRun?: boolean; env?: Env }): Result {
|
|
683
818
|
const st = loadState(home)
|
|
684
819
|
const rec = st.tools[c.id]
|
|
685
820
|
if (!rec) return { ok: true, lines: [`${c.label} is not connected by brain; nothing to undo`] }
|
|
@@ -744,11 +879,12 @@ export function status(home: string, env: Record<string, string | undefined> = p
|
|
|
744
879
|
|
|
745
880
|
// ---- the command --------------------------------------------------------------------------------------------------------
|
|
746
881
|
|
|
882
|
+
|
|
747
883
|
const USAGE = [
|
|
748
884
|
"brain gateway login [--no-browser] sign in to 100xprompt in your browser, as `100xprompt auth login` does",
|
|
749
885
|
"brain gateway login --key-env VAR | --from-cli take the key from VAR (scripts, CI) or from the 100xprompt CLI's sign-in",
|
|
750
886
|
"brain gateway logout forget the key",
|
|
751
|
-
"brain gateway connect --tool <id> [--model pro|flash] [--route flash] [--url https://...] [--no-context-edit | --context-edit] [--dry-run] [--no-probe]",
|
|
887
|
+
"brain gateway connect --tool <id> [--model pro|flash|auto] [--route flash] [--url https://...] [--no-context-edit | --context-edit] [--dry-run] [--no-probe]",
|
|
752
888
|
"brain gateway disconnect --tool <id> [--dry-run]",
|
|
753
889
|
"brain gateway status | tools",
|
|
754
890
|
]
|
|
@@ -782,6 +918,8 @@ export async function gatewayCommand(
|
|
|
782
918
|
}
|
|
783
919
|
const refreshedNote = (labels: string[]) => {
|
|
784
920
|
if (labels.length) io.out(`the new key also replaced the old one in ${labels.join(" and ")}'s own config`)
|
|
921
|
+
// Signed in but not paired: the one step that puts this computer's settings in the dashboard.
|
|
922
|
+
if (!readMachineToken(home)) io.out("to see this computer's settings in your dashboard: brain pair")
|
|
785
923
|
}
|
|
786
924
|
// The browser sign-in, with a hidden paste prompt when there is a terminal to ask in.
|
|
787
925
|
const browserLogin = async () => {
|
|
@@ -814,25 +952,43 @@ export async function gatewayCommand(
|
|
|
814
952
|
io.out(`usage: ${USAGE[sub === "connect" ? 3 : 4]}`)
|
|
815
953
|
return 2
|
|
816
954
|
}
|
|
817
|
-
|
|
955
|
+
// What connect and disconnect do is recorded as this computer's settings (`brain settings` shows who set what),
|
|
956
|
+
// inside the same hold of the brain lock as the change itself. Loaded here, before connecting: the settings modules
|
|
957
|
+
// import this one.
|
|
958
|
+
const { recordConnected } = await import("../settings/apply.ts")
|
|
959
|
+
if (sub === "disconnect") {
|
|
960
|
+
if (has("dry-run")) return print(disconnect(home, tool, { dryRun: true, env }))
|
|
961
|
+
const r = withBrainLockSync(
|
|
962
|
+
home,
|
|
963
|
+
() => {
|
|
964
|
+
const d = disconnect(home, tool, { env })
|
|
965
|
+
if (d.ok && connectorFor(tool)) recordConnected(home, connectorFor(tool)!.id, undefined)
|
|
966
|
+
return d
|
|
967
|
+
},
|
|
968
|
+
{ what: "disconnecting a tool" },
|
|
969
|
+
)
|
|
970
|
+
return print(r)
|
|
971
|
+
}
|
|
818
972
|
const settings = { ...(flag("url") ? { url: flag("url")! } : {}), ...(flag("route") ? { route: flag("route")! } : {}), ...(flag("model") ? { model: flag("model")! } : {}), ...(has("no-context-edit") ? { contextEdit: false } : has("context-edit") ? { contextEdit: true } : {}) }
|
|
819
973
|
// Not signed in yet: sign in first, as the 100xprompt CLI would, then connect. Everything that can be checked here
|
|
820
974
|
// (the tool, its files, the flags) is checked first, so nobody signs in for a connect that was bound to fail.
|
|
821
975
|
if (!hasKey(home) && !has("dry-run")) {
|
|
822
|
-
const pre = await connect(home, tool, { settings, dryRun: true, env })
|
|
976
|
+
const pre = await connect(home, tool, { settings, dryRun: true, env, usePrefs: true })
|
|
823
977
|
if (!pre.ok) return print(pre)
|
|
824
978
|
io.out("not signed in to the gateway yet; signing in first")
|
|
825
979
|
if (!(await browserLogin())) return 1
|
|
826
980
|
}
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
}),
|
|
835
|
-
|
|
981
|
+
const id = connectorFor(tool)?.id
|
|
982
|
+
const r = await connect(home, tool, {
|
|
983
|
+
settings,
|
|
984
|
+
dryRun: has("dry-run"),
|
|
985
|
+
probe: !has("no-probe"),
|
|
986
|
+
env,
|
|
987
|
+
usePrefs: true,
|
|
988
|
+
...(id ? { onWritten: (s: GatewaySettings) => recordConnected(home, id, s) } : {}),
|
|
989
|
+
...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}),
|
|
990
|
+
})
|
|
991
|
+
return print(r)
|
|
836
992
|
}
|
|
837
993
|
case "status":
|
|
838
994
|
status(home, env).forEach((l) => io.out(l))
|
|
@@ -29,15 +29,12 @@
|
|
|
29
29
|
// Docs: https://aider.chat/docs/config/adv-model-settings.html (extra_params, extra_headers, metadata file),
|
|
30
30
|
// https://aider.chat/docs/llms/openai-compat.html (openai/<model> against an OpenAI-compatible API).
|
|
31
31
|
import { join } from "node:path"
|
|
32
|
-
import { type Codec, type Connector, EDIT_HEADER, type Json, readKey, routeUrl, yamlTopLevel } from "../common.ts"
|
|
32
|
+
import { type Codec, type Connector, EDIT_HEADER, type Json, limitsFor, readKey, routeUrl, yamlTopLevel } from "../common.ts"
|
|
33
33
|
|
|
34
34
|
/** The one key of brain's own entry in the settings list and in the metadata object. */
|
|
35
35
|
export const AIDER_ENTRY = "brain-gateway"
|
|
36
36
|
/** Marks brain's entry in each file (a YAML comment, a JSON5 comment), so it is told apart from the person's own. */
|
|
37
37
|
export const AIDER_MARK = "100xprompt gateway, added by brain gateway connect --tool aider"
|
|
38
|
-
/** What Aider is told about the gateway's model: the Gemini Flash models' window and output limit. */
|
|
39
|
-
const CONTEXT = 1_048_576
|
|
40
|
-
const OUTPUT = 65_536
|
|
41
38
|
|
|
42
39
|
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
43
40
|
|
|
@@ -337,7 +334,8 @@ export const connectors: Connector[] = [
|
|
|
337
334
|
path: join(home, ".aider.model.metadata.json"),
|
|
338
335
|
format: "custom",
|
|
339
336
|
codec: modelMetadataJson,
|
|
340
|
-
|
|
337
|
+
// The Gemini Flash models' window and output limit, or for 100xprompt Auto what every model it may serve takes.
|
|
338
|
+
values: { [AIDER_ENTRY]: { [model]: { max_input_tokens: limitsFor(s).context, max_output_tokens: limitsFor(s).output, max_tokens: limitsFor(s).output, litellm_provider: "openai", mode: "chat" } } },
|
|
341
339
|
},
|
|
342
340
|
{
|
|
343
341
|
path: join(home, ".aider.conf.yml"),
|
|
@@ -26,12 +26,9 @@
|
|
|
26
26
|
// not followed; CLINE_DIR and CLINE_DATA_DIR are, when they exist. The VS Code extension (id `cline`, see cline.ts) shares
|
|
27
27
|
// ~/.cline/data but chooses its provider from its own globalState.json, so this connector does not change it.
|
|
28
28
|
import { join } from "node:path"
|
|
29
|
-
import { type Connector, dirFromEnv, EDIT_HEADER, type Env, modelName, readKey, routeUrl } from "../common.ts"
|
|
29
|
+
import { type Connector, dirFromEnv, EDIT_HEADER, type Env, limitsFor, modelName, readKey, routeUrl } from "../common.ts"
|
|
30
30
|
|
|
31
31
|
const ID = "brain-gateway"
|
|
32
|
-
/** Gemini 3.x Flash, which the gateway's models are: a 1M-token window, 64K out. */
|
|
33
|
-
const CONTEXT_WINDOW = 1_048_576
|
|
34
|
-
const MAX_OUTPUT = 65_536
|
|
35
32
|
/** Cline requires an ISO date here; a fixed one keeps edits() free of side effects (Cline sets its own when it saves). */
|
|
36
33
|
const UPDATED_AT = "2026-01-01T00:00:00.000Z"
|
|
37
34
|
|
|
@@ -56,7 +53,7 @@ export const connectors: Connector[] = [
|
|
|
56
53
|
version: 1,
|
|
57
54
|
[`providers/${ID}`]: {
|
|
58
55
|
provider: { name: "100xprompt gateway", baseUrl, defaultModelId: s.model, protocol: "openai-chat", client: "openai-compatible", capabilities: ["tools", "streaming"] },
|
|
59
|
-
models: { [s.model]: { id: s.model, name: modelName(s.model), contextWindow:
|
|
56
|
+
models: { [s.model]: { id: s.model, name: modelName(s.model), contextWindow: limitsFor(s).context, maxTokens: limitsFor(s).output, capabilities: ["tools", "streaming", "images"] } },
|
|
60
57
|
},
|
|
61
58
|
},
|
|
62
59
|
},
|