@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
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
// apiKey: ${{ secrets.BRAIN_GATEWAY_KEY }}, roles chat/edit/apply/summarize,
|
|
31
31
|
// capabilities tool_use and image_input (Continue only guesses tool support for provider openai from
|
|
32
32
|
// gpt-/o- model names, toolSupport.ts ~277661, so without it Agent mode would be off),
|
|
33
|
-
// contextLength 1048576 (an unknown model gets 32768, ~238017
|
|
33
|
+
// contextLength 1048576 (an unknown model gets 32768, ~238017; for 100xprompt Auto the
|
|
34
|
+
// smallest window auto may serve, common.ts limitsFor), and the context-edit header.
|
|
34
35
|
// First in the list makes it the default: the extension and the CLI take the first chat model unless
|
|
35
36
|
// one was picked before (extension rectifySelectedModelsFromGlobalContext ~798703; CLI
|
|
36
37
|
// services/ModelService.ts:53-120 and util/modelPersistence.ts, `cliSelectedModel`).
|
|
@@ -42,7 +43,7 @@
|
|
|
42
43
|
// stay, and removing the entry gives back the exact bytes, including an empty `models: []` or a list that was not there.
|
|
43
44
|
import { existsSync, readFileSync } from "node:fs"
|
|
44
45
|
import { isAbsolute, join, resolve } from "node:path"
|
|
45
|
-
import { type Codec, type Connector, EDIT_HEADER, type Env, type GatewaySettings, type Json, modelName, readKey, routeUrl, yamlTopLevel } from "../common.ts"
|
|
46
|
+
import { type Codec, type Connector, EDIT_HEADER, type Env, type GatewaySettings, type Json, limitsFor, modelName, readKey, routeUrl, yamlTopLevel } from "../common.ts"
|
|
46
47
|
|
|
47
48
|
/** The name the key is kept under in the tool's .env, and that its config refers to. */
|
|
48
49
|
export const SECRET = "BRAIN_GATEWAY_KEY"
|
|
@@ -50,8 +51,6 @@ export const SECRET = "BRAIN_GATEWAY_KEY"
|
|
|
50
51
|
const OWN_CONFIG = "100xprompt gateway"
|
|
51
52
|
/** The codec key of brain's entry in the models list (the entry itself is found by its apiKey, so a model switch keeps it). */
|
|
52
53
|
export const ENTRY_KEY = "models/100xprompt gateway"
|
|
53
|
-
/** Gemini models' context window; Continue assumes 32768 for a model it does not know. */
|
|
54
|
-
const CONTEXT = 1048576
|
|
55
54
|
|
|
56
55
|
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
|
|
57
56
|
const isPlainObject = (v: unknown): v is Json => !!v && typeof v === "object" && !Array.isArray(v)
|
|
@@ -521,7 +520,7 @@ const headers = (s: GatewaySettings) => (s.contextEdit ? { requestOptions: { hea
|
|
|
521
520
|
|
|
522
521
|
/** The models entry in config.json form (older Continue, PearAI): `title`, the key by env name. */
|
|
523
522
|
export function jsonEntry(s: GatewaySettings, extra: Json = {}): Json {
|
|
524
|
-
return { title: modelName(s.model), provider: "openai", model: s.model, apiBase: `${routeUrl(s)}/v1`, apiKey: SECRET, contextLength:
|
|
523
|
+
return { title: modelName(s.model), provider: "openai", model: s.model, apiBase: `${routeUrl(s)}/v1`, apiKey: SECRET, contextLength: limitsFor(s).context, ...extra, ...headers(s) }
|
|
525
524
|
}
|
|
526
525
|
|
|
527
526
|
/** The .env edit both Continue and PearAI read their secret from. */
|
|
@@ -557,7 +556,7 @@ export const connectors: Connector[] = [
|
|
|
557
556
|
apiKey: `\${{ secrets.${SECRET} }}`,
|
|
558
557
|
roles: ["chat", "edit", "apply", "summarize"],
|
|
559
558
|
capabilities: ["tool_use", "image_input"],
|
|
560
|
-
defaultCompletionOptions: { contextLength:
|
|
559
|
+
defaultCompletionOptions: { contextLength: limitsFor(s).context },
|
|
561
560
|
...headers(s),
|
|
562
561
|
}
|
|
563
562
|
return [
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
// A model list is given, so Crush does not ask the gateway for /v1/models (403 there).
|
|
31
31
|
import { existsSync } from "node:fs"
|
|
32
32
|
import { isAbsolute, join, resolve } from "node:path"
|
|
33
|
-
import { type Codec, type Connector, EDIT_HEADER, type Env, getAt, type Json, keyPath, modelName, routeUrl, shellQuote } from "../common.ts"
|
|
33
|
+
import { type Codec, type Connector, EDIT_HEADER, type Env, getAt, type Json, keyPath, limitsFor, modelName, routeUrl, shellQuote } from "../common.ts"
|
|
34
34
|
|
|
35
35
|
/** Crush's data file, resolved as Crush does; the env overrides describe the person's real home, so they count only there. */
|
|
36
36
|
export function crushDataFile(home: string, env: Env): string {
|
|
@@ -297,7 +297,7 @@ export const connectors: Connector[] = [
|
|
|
297
297
|
base_url: `${routeUrl(s)}/v1`,
|
|
298
298
|
api_key: `$(cat ${shellQuote(keyPath(home))})`,
|
|
299
299
|
...(s.contextEdit ? { extra_headers: { [EDIT_HEADER]: "on" } } : {}),
|
|
300
|
-
models: [{ id: s.model, name: modelName(s.model), context_window:
|
|
300
|
+
models: [{ id: s.model, name: modelName(s.model), context_window: limitsFor(s).context, default_max_tokens: limitsFor(s).output, capabilities: { vision: true } }],
|
|
301
301
|
},
|
|
302
302
|
"models/large": pick,
|
|
303
303
|
"models/small": pick,
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
// stdout."), run with shell:true, cached for 5 minutes (apiKeyHelperTtlMs, FACTORY_API_KEY_HELPER_TTL_MS). So the key
|
|
15
15
|
// is read from the brain's key file at run time, never copied, and a new sign-in reaches Droid without a reconnect.
|
|
16
16
|
// - `extraHeaders` is sent with every request (case-insensitively unique names), so the context-edit header can go.
|
|
17
|
+
// - Context window and output limit: Droid infers a custom model's from its model id (the built-in model whose
|
|
18
|
+
// matchPatterns match it: gemini-3.8-flash gets Gemini 3.8 Flash's 1M in and 64K out), and an entry's maxContextLimit
|
|
19
|
+
// and maxOutputTokens take precedence (k4e and ev in the binary). 100xprompt Auto's id matches nothing, so for auto they
|
|
20
|
+
// are given: what every model auto may serve takes (common.ts limitsFor). Images stay on (noImageSupport unset).
|
|
17
21
|
// - A model's id is `custom:<displayName with spaces as ->-<n>` unless the entry names its own `id`; this connector gives
|
|
18
22
|
// its entry the id `custom:brain-gateway`, so the default stays put whatever else is in the list.
|
|
19
23
|
// - The default model is `sessionDefaultSettings.model` (older top-level `model` is folded into it on read and only
|
|
@@ -27,7 +31,7 @@
|
|
|
27
31
|
// - Checked for real (scripts/gateway-real/factory-droid.ts): `droid exec` with no -m, no Factory login, sends its
|
|
28
32
|
// request to <route>/v1/messages with x-api-key from the helper, the context-edit header and the gateway's model.
|
|
29
33
|
import { join } from "node:path"
|
|
30
|
-
import { type Codec, type Connector, dirFromEnv, EDIT_HEADER, type Env, type Json, keyPath, modelName, routeUrl, shellQuote } from "../common.ts"
|
|
34
|
+
import { type Codec, type Connector, dirFromEnv, EDIT_HEADER, type Env, isAuto, type Json, keyPath, limitsFor, modelName, routeUrl, shellQuote } from "../common.ts"
|
|
31
35
|
|
|
32
36
|
/** The id of the connector's entry in customModels, and the default model value that selects it. */
|
|
33
37
|
export const DROID_MODEL_ID = "custom:brain-gateway"
|
|
@@ -295,6 +299,7 @@ export const connectors: Connector[] = [
|
|
|
295
299
|
provider: "anthropic",
|
|
296
300
|
apiKeyHelper: `cat ${shellQuote(keyPath(home))}`,
|
|
297
301
|
...(s.contextEdit ? { extraHeaders: { [EDIT_HEADER]: "on" } } : {}),
|
|
302
|
+
...(isAuto(s) ? { maxContextLimit: limitsFor(s).context, maxOutputTokens: limitsFor(s).output } : {}),
|
|
298
303
|
},
|
|
299
304
|
"sessionDefaultSettings/model": DROID_MODEL_ID,
|
|
300
305
|
},
|
|
@@ -22,12 +22,10 @@
|
|
|
22
22
|
// The binary's strings list the same field names (`strings goose | grep api_key_env`), and the real-tool check
|
|
23
23
|
// (scripts/gateway-real/goose.ts) runs this exact setup.
|
|
24
24
|
import { isAbsolute, join, resolve } from "node:path"
|
|
25
|
-
import { type Connector, EDIT_HEADER, type Env, keyPath, modelName, routeUrl, yamlTopLevel } from "../common.ts"
|
|
25
|
+
import { type Connector, EDIT_HEADER, type Env, keyPath, limitsFor, modelName, routeUrl, yamlTopLevel } from "../common.ts"
|
|
26
26
|
|
|
27
27
|
/** The provider id (the custom provider's file name and `name`, and GOOSE_PROVIDER), as Codex and OpenCode name it. */
|
|
28
28
|
export const GOOSE_PROVIDER = "brain-gateway"
|
|
29
|
-
/** The context window Goose is told (it compacts the conversation near it): the Gemini Flash models' 1,048,576 tokens. */
|
|
30
|
-
const CONTEXT_LIMIT = 1_048_576
|
|
31
29
|
|
|
32
30
|
/** Goose's config folder, found the way Goose finds it. XDG_CONFIG_HOME describes the real home, so it counts only there. */
|
|
33
31
|
export function gooseDir(home: string, env: Env): string {
|
|
@@ -56,7 +54,9 @@ export const connectors: Connector[] = [
|
|
|
56
54
|
display_name: "100xprompt gateway",
|
|
57
55
|
description: `${modelName(s.model)} through the 100xprompt gateway (set up by brain gateway connect --tool goose)`,
|
|
58
56
|
base_url: `${routeUrl(s)}/v1`,
|
|
59
|
-
|
|
57
|
+
// The window Goose is told (it compacts the conversation near it): the Gemini Flash models' 1,048,576 tokens, or
|
|
58
|
+
// for 100xprompt Auto the smallest window it may serve (common.ts limitsFor).
|
|
59
|
+
models: [{ name: s.model, context_limit: limitsFor(s).context }],
|
|
60
60
|
...(s.contextEdit ? { headers: { [EDIT_HEADER]: "on" } } : {}),
|
|
61
61
|
supports_streaming: true,
|
|
62
62
|
requires_auth: true,
|
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
// - `extra_headers` on the provider entry is sent with every request on the OpenAI wire (runtime_provider.py
|
|
20
20
|
// _lift_extra_headers), so the context-edit header can go.
|
|
21
21
|
// - The gateway's /v1/models answers 403; Hermes then takes the context window from its catalog ("gemini" → 1M,
|
|
22
|
-
// agent/model_metadata.py), so nothing else needs setting.
|
|
22
|
+
// agent/model_metadata.py), so nothing else needs setting for a Gemini id. 100xprompt Auto's id is in no catalog (Hermes
|
|
23
|
+
// would assume 256,000: DEFAULT_FALLBACK_CONTEXT), so for auto the entry also gets `models: {<id>: {context_length}}`,
|
|
24
|
+
// the per-model override Hermes checks before any catalog (hermes_cli/config.py get_custom_provider_context_length,
|
|
25
|
+
// matched by the entry's base_url): what every model auto may serve takes (common.ts limitsFor).
|
|
23
26
|
// - $HERMES_HOME replaces ~/.hermes when set (a Hermes profile is its own HERMES_HOME).
|
|
24
27
|
// - Checked for real (scripts/gateway-real/hermes.ts): `hermes chat -q` sends its request to <route>/v1/chat/completions
|
|
25
28
|
// with Authorization: Bearer <key from .env>, the context-edit header and the gateway's model, even with the person's
|
|
@@ -27,7 +30,7 @@
|
|
|
27
30
|
// config.yaml is YAML with comments; the codec below edits `<section>/<entry>` lines in place and leaves every other line
|
|
28
31
|
// as it was.
|
|
29
32
|
import { join } from "node:path"
|
|
30
|
-
import { type Codec, type Connector, dirFromEnv, EDIT_HEADER, type Env, type Json, readKey, routeUrl, yamlTopLevel } from "../common.ts"
|
|
33
|
+
import { type Codec, type Connector, dirFromEnv, EDIT_HEADER, type Env, isAuto, type Json, limitsFor, readKey, routeUrl, yamlTopLevel } from "../common.ts"
|
|
31
34
|
|
|
32
35
|
export const HERMES_PROVIDER = "brain-gateway"
|
|
33
36
|
export const HERMES_KEY_VAR = "BRAIN_GATEWAY_API_KEY"
|
|
@@ -102,7 +105,7 @@ function section(lines: string[], name: string): Section | undefined {
|
|
|
102
105
|
const at = lines.findIndex((l) => keyLine("", name).test(l))
|
|
103
106
|
if (at < 0) return undefined
|
|
104
107
|
const inline = inlineValue(lines[at]!)
|
|
105
|
-
if (inline && !/^(?:\{\s*\}|null|~)$/.test(inline)) throw new Error(`${name}: is written on one line
|
|
108
|
+
if (inline && !/^(?:\{\s*\}|null|~)$/.test(inline)) throw new Error(`${name}: is written on one line, not as a section`)
|
|
106
109
|
const end = blockEnd(lines, at)
|
|
107
110
|
const first = lines.slice(at + 1, end).find((l) => !blank(l))
|
|
108
111
|
const indent = first ? indentOf(first) : ""
|
|
@@ -209,6 +212,7 @@ export const connectors: Connector[] = [
|
|
|
209
212
|
api_mode: "chat_completions",
|
|
210
213
|
default_model: s.model,
|
|
211
214
|
...(s.contextEdit ? { extra_headers: { [EDIT_HEADER]: "on" } } : {}),
|
|
215
|
+
...(isAuto(s) ? { models: { [s.model]: { context_length: limitsFor(s).context } } } : {}),
|
|
212
216
|
},
|
|
213
217
|
"model/provider": `custom:${HERMES_PROVIDER}`,
|
|
214
218
|
"model/default": s.model,
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
// back the exact bytes. Before any change the codec makes a trial edit and refuses a file where that would not land
|
|
27
27
|
// exactly on brain's entries (an inline `providers = {...}`, dotted keys, sub-tables of brain's tables, ...).
|
|
28
28
|
import { join } from "node:path"
|
|
29
|
-
import { type Codec, type Connector, dirFromEnv, EDIT_HEADER, type Json, modelName, readKey, routeUrl } from "../common.ts"
|
|
29
|
+
import { type Codec, type Connector, dirFromEnv, EDIT_HEADER, type Json, limitsFor, modelName, readKey, routeUrl } from "../common.ts"
|
|
30
30
|
|
|
31
31
|
export const kimiConfig = (home: string, env: Record<string, string | undefined>) => join(dirFromEnv(env, "KIMI_CODE_HOME", join(home, ".kimi-code")), "config.toml")
|
|
32
32
|
|
|
@@ -227,7 +227,7 @@ export const connectors: Connector[] = [
|
|
|
227
227
|
[`models/${PROVIDER}`]: {
|
|
228
228
|
provider: PROVIDER,
|
|
229
229
|
model: s.model,
|
|
230
|
-
max_context_size:
|
|
230
|
+
max_context_size: limitsFor(s).context,
|
|
231
231
|
capabilities: ["image_in", "tool_use"],
|
|
232
232
|
display_name: modelName(s.model),
|
|
233
233
|
},
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
// (src/backend/dev/pi-request-headers.ts).
|
|
34
34
|
import { existsSync } from "node:fs"
|
|
35
35
|
import { isAbsolute, join } from "node:path"
|
|
36
|
-
import { type Codec, type Connector, EDIT_HEADER, type Env, type GatewaySettings, type Json, keyPath, modelName, routeUrl } from "../common.ts"
|
|
36
|
+
import { type Codec, type Connector, EDIT_HEADER, type Env, type GatewaySettings, type Json, keyPath, limitsFor, modelName, routeUrl } from "../common.ts"
|
|
37
37
|
|
|
38
38
|
export const MOD_FILE = "brain-gateway.mjs"
|
|
39
39
|
const MARK = "// Written by `brain gateway connect --tool letta-code`"
|
|
@@ -136,18 +136,15 @@ export const lettaMod: Codec = {
|
|
|
136
136
|
},
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
/** What 100xprompt's models take (Gemini 3.x Flash: 1M tokens in, 64k out). */
|
|
140
|
-
const CONTEXT_WINDOW = 1_048_576
|
|
141
|
-
const MAX_TOKENS = 65_536
|
|
142
|
-
|
|
143
139
|
const modValues = (home: string, s: GatewaySettings): Record<string, unknown> => ({
|
|
144
140
|
provider: "brain-gateway",
|
|
145
141
|
name: "100xprompt gateway",
|
|
146
142
|
baseUrl: `${routeUrl(s)}/v1`,
|
|
147
143
|
model: s.model,
|
|
148
144
|
modelName: modelName(s.model),
|
|
149
|
-
|
|
150
|
-
|
|
145
|
+
// What the model takes (Gemini 3.x Flash: 1M tokens in, 64k out; for 100xprompt Auto, what every model it may serve takes).
|
|
146
|
+
contextWindow: limitsFor(s).context,
|
|
147
|
+
maxTokens: limitsFor(s).output,
|
|
151
148
|
keyFile: keyPath(home),
|
|
152
149
|
...(s.contextEdit ? { headers: { [EDIT_HEADER]: "on" } } : {}),
|
|
153
150
|
})
|
|
@@ -25,9 +25,14 @@
|
|
|
25
25
|
// (routing metadata for the previous endpoint; chat_options.py:83-85 sends it as extra_body). All of them come back
|
|
26
26
|
// on disconnect.
|
|
27
27
|
// - OpenHands has no named providers or model list (no profile is used by the CLI), so the LLM entry itself is switched.
|
|
28
|
+
// - With max_input_tokens and max_output_tokens null, the SDK looks the model up in LiteLLM's catalog, under the id and
|
|
29
|
+
// under the part after the last "/" (sdk/llm/utils/model_info.py get_litellm_model_info): gemini-3.8-flash is there
|
|
30
|
+
// (1,048,576 in, 65,536 out, images). 100xprompt Auto's id is not, so for auto both are set to what every model auto may
|
|
31
|
+
// serve takes (common.ts limitsFor). Images: OpenHands decides them from that same catalog only (llm.py
|
|
32
|
+
// _supports_vision; there is no setting to turn them on), so with auto it sends no images.
|
|
28
33
|
import { readFileSync } from "node:fs"
|
|
29
34
|
import { join } from "node:path"
|
|
30
|
-
import { type Codec, type Connector, dirFromEnv, EDIT_HEADER, type Env, type GatewaySettings, type Json, readKey, routeUrl } from "../common.ts"
|
|
35
|
+
import { type Codec, type Connector, dirFromEnv, EDIT_HEADER, type Env, type GatewaySettings, isAuto, type Json, limitsFor, readKey, routeUrl } from "../common.ts"
|
|
31
36
|
|
|
32
37
|
// ---- JSON edited in place ------------------------------------------------------------------------------------------------
|
|
33
38
|
|
|
@@ -261,8 +266,8 @@ const llmValues = (prefix: string, home: string, s: GatewaySettings): Record<str
|
|
|
261
266
|
[`${prefix}/api_key`]: readKey(home),
|
|
262
267
|
[`${prefix}/extra_headers`]: s.contextEdit ? { [EDIT_HEADER]: "on" } : null,
|
|
263
268
|
[`${prefix}/model_canonical_name`]: null,
|
|
264
|
-
[`${prefix}/max_input_tokens`]: null,
|
|
265
|
-
[`${prefix}/max_output_tokens`]: null,
|
|
269
|
+
[`${prefix}/max_input_tokens`]: isAuto(s) ? limitsFor(s).context : null,
|
|
270
|
+
[`${prefix}/max_output_tokens`]: isAuto(s) ? limitsFor(s).output : null,
|
|
266
271
|
[`${prefix}/litellm_extra_body`]: {},
|
|
267
272
|
})
|
|
268
273
|
|
|
@@ -289,7 +294,7 @@ export const connectors: Connector[] = [
|
|
|
289
294
|
},
|
|
290
295
|
notes: [
|
|
291
296
|
"OpenHands keeps the key in its agent_settings.json (it has no key-file option), so the file is now readable by you only (0600); a new sign-in replaces the key there.",
|
|
292
|
-
"Its agent and memory-condenser LLM now use the gateway's model over OpenAI Chat Completions; the model limits OpenHands had stored for your previous model are cleared so it looks them up again.",
|
|
297
|
+
"Its agent and memory-condenser LLM now use the gateway's model over OpenAI Chat Completions; the model limits OpenHands had stored for your previous model are cleared so it looks them up again (with 100xprompt Auto they are set instead, and OpenHands sends no images: it decides that from the model's name alone).",
|
|
293
298
|
"Run without --override-with-envs (or without LLM_API_KEY, LLM_BASE_URL and LLM_MODEL set): those override this file. Changing the LLM in OpenHands' settings screen replaces these values, and disconnect then leaves your choice alone.",
|
|
294
299
|
],
|
|
295
300
|
},
|
|
@@ -20,13 +20,18 @@
|
|
|
20
20
|
// under a name of its own (BRAIN_GATEWAY_KEY, so no OPENAI_API_KEY of the person's is touched), and that file is 0600.
|
|
21
21
|
// - Custom headers: generationConfig.customHeaders is sent with every request (chunks/chunk-ESPAP3UW.js buildHeaders),
|
|
22
22
|
// so `x-100x-context-edit: on` goes there.
|
|
23
|
+
// - Context window and input kinds: Qwen Code takes them from the model id (chunks/chunk-5TODR7F6.js tokenLimit and
|
|
24
|
+
// defaultModalities: gemini-* gets 1,000,000 tokens and every input kind; an id it does not know gets 200,000 and text
|
|
25
|
+
// only), unless the provider entry's generationConfig gives contextWindowSize and modalities (chunks/chunk-KUBWE5YV.js
|
|
26
|
+
// ModelRegistry, MODEL_GENERATION_CONFIG_FIELDS). 100xprompt Auto's id is one it cannot know, so for auto they are given:
|
|
27
|
+
// what every model auto may serve takes (common.ts limitsFor), images and text.
|
|
23
28
|
// What connect writes: settings.json gets a "brain-gateway" provider ("100xprompt gateway", model shown as its 100xprompt
|
|
24
29
|
// name), its providerProtocol mapping, and the selection (auth type, model.name, model.baseUrl); ~/.qwen/.env gets the
|
|
25
30
|
// key. The person's own providers, models and comments are left as they are; disconnect puts every key back.
|
|
26
31
|
// Needs Qwen Code 0.23 or later (custom provider ids through providerProtocol); older ones skip the provider.
|
|
27
32
|
import { existsSync } from "node:fs"
|
|
28
33
|
import { join } from "node:path"
|
|
29
|
-
import { type Codec, type Connector, EDIT_HEADER, type Env, type GatewaySettings, type Json, modelName, readKey, routeUrl } from "../common.ts"
|
|
34
|
+
import { type Codec, type Connector, EDIT_HEADER, type Env, type GatewaySettings, isAuto, type Json, limitsFor, modelName, readKey, routeUrl } from "../common.ts"
|
|
30
35
|
|
|
31
36
|
// ---- JSON with comments, edited in place ----------------------------------------------------------------------------
|
|
32
37
|
//
|
|
@@ -372,6 +377,15 @@ export function qwenDir(home: string, env: Env): string {
|
|
|
372
377
|
|
|
373
378
|
const base = (s: GatewaySettings) => `${routeUrl(s)}/v1`
|
|
374
379
|
|
|
380
|
+
/** The provider entry's generationConfig: the context-edit header, and for auto the window and input kinds (see above). */
|
|
381
|
+
function generationConfig(s: GatewaySettings): Json | undefined {
|
|
382
|
+
const g: Json = {
|
|
383
|
+
...(s.contextEdit ? { customHeaders: { [EDIT_HEADER]: "on" } } : {}),
|
|
384
|
+
...(isAuto(s) ? { contextWindowSize: limitsFor(s).context, modalities: { image: true } } : {}),
|
|
385
|
+
}
|
|
386
|
+
return Object.keys(g).length ? g : undefined
|
|
387
|
+
}
|
|
388
|
+
|
|
375
389
|
export const connectors: Connector[] = [
|
|
376
390
|
{
|
|
377
391
|
id: "qwen-code",
|
|
@@ -392,7 +406,7 @@ export const connectors: Connector[] = [
|
|
|
392
406
|
description: "100xprompt gateway",
|
|
393
407
|
baseUrl: base(s),
|
|
394
408
|
envKey: QWEN_KEY_VAR,
|
|
395
|
-
...(s
|
|
409
|
+
...(generationConfig(s) ? { generationConfig: generationConfig(s) } : {}),
|
|
396
410
|
},
|
|
397
411
|
],
|
|
398
412
|
[`providerProtocol/${PROVIDER}`]: "openai",
|
|
@@ -27,13 +27,10 @@
|
|
|
27
27
|
// Only the default VS Code profile and VS Code Stable ("Code") are handled; Insiders, VSCodium and other profiles keep
|
|
28
28
|
// their own User folders.
|
|
29
29
|
import { join, resolve } from "node:path"
|
|
30
|
-
import { type Codec, type Connector, EDIT_HEADER, type Env, type Json, modelName, readKey, routeUrl } from "../common.ts"
|
|
30
|
+
import { type Codec, type Connector, EDIT_HEADER, type Env, type Json, limitsFor, modelName, readKey, routeUrl } from "../common.ts"
|
|
31
31
|
|
|
32
32
|
const GROUP = "100xprompt gateway"
|
|
33
33
|
const VENDOR = "customendpoint"
|
|
34
|
-
/** Gemini 3.x Flash, which the gateway's models are: a 1M-token window, 64K out. */
|
|
35
|
-
const MAX_OUTPUT = 65_536
|
|
36
|
-
const MAX_INPUT = 1_048_576 - MAX_OUTPUT
|
|
37
34
|
|
|
38
35
|
/** VS Code's user folder: ~/Library/Application Support/Code/User on macOS, $XDG_CONFIG_HOME/Code/User or ~/.config/Code/User elsewhere. */
|
|
39
36
|
export function vscodeUserDir(home: string, env: Env, platform: string = process.platform): string {
|
|
@@ -268,8 +265,9 @@ export const connectors: Connector[] = [
|
|
|
268
265
|
apiType: "chat-completions",
|
|
269
266
|
toolCalling: true,
|
|
270
267
|
vision: true,
|
|
271
|
-
|
|
272
|
-
|
|
268
|
+
// The window less the output (Gemini 3.x Flash: 1M in, 64K out; 100xprompt Auto: what all it may serve take).
|
|
269
|
+
maxInputTokens: limitsFor(s).context - limitsFor(s).output,
|
|
270
|
+
maxOutputTokens: limitsFor(s).output,
|
|
273
271
|
requestHeaders: { Authorization: `Bearer ${readKey(home)}`, ...(s.contextEdit ? { [EDIT_HEADER]: "on" } : {}) },
|
|
274
272
|
},
|
|
275
273
|
],
|
package/src/gateway/signin.ts
CHANGED
|
@@ -118,8 +118,38 @@ export interface SignInDeps {
|
|
|
118
118
|
|
|
119
119
|
export type SignIn = { ok: true; key: string; email?: string; via: "browser" | "code" } | { ok: false; why: string }
|
|
120
120
|
|
|
121
|
+
type Took = { ok: true; key: string; email?: string } | { ok: false; why: string }
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Which browser round trip to run: the sign-in (`brain gateway login`: /auth/cli, a gateway key comes back) or pairing
|
|
125
|
+
* (`brain pair`: /auth/brain, the computer's own token comes back). Everything else (the loopback server, the state,
|
|
126
|
+
* the paste fallback) is the same.
|
|
127
|
+
*/
|
|
128
|
+
export interface BrowserFlow {
|
|
129
|
+
/** The command the person ran, named in the refusal page. */
|
|
130
|
+
command: string
|
|
131
|
+
/** The site's page, and what else it is told. */
|
|
132
|
+
path: string
|
|
133
|
+
params?: Record<string, string>
|
|
134
|
+
/** What a callback's token or a pasted text must be to be kept. */
|
|
135
|
+
accept(token: string): Took
|
|
136
|
+
/** Words: what the browser is opened for, the page shown when it worked, and what to paste. */
|
|
137
|
+
opening: string
|
|
138
|
+
done: (email?: string) => { title: string; body: string }
|
|
139
|
+
paste: string
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export const SIGN_IN_FLOW: BrowserFlow = {
|
|
143
|
+
command: "brain gateway login",
|
|
144
|
+
path: "/auth/cli",
|
|
145
|
+
accept: (token) => callbackKey(token),
|
|
146
|
+
opening: "sign in to 100xprompt",
|
|
147
|
+
done: (email) => ({ title: "Signed in", body: `brain is now signed in to the 100xprompt gateway${email ? ` as ${email}` : ""}. You can close this tab and go back to your terminal.` }),
|
|
148
|
+
paste: "Paste the key from the page's Copy key button, or the address the browser could not open",
|
|
149
|
+
}
|
|
150
|
+
|
|
121
151
|
/** Signs in through the browser (or a pasted code). Does not store anything: the caller does. */
|
|
122
|
-
export async function browserSignIn(d: SignInDeps): Promise<SignIn> {
|
|
152
|
+
export async function browserSignIn(d: SignInDeps, flow: BrowserFlow = SIGN_IN_FLOW): Promise<SignIn> {
|
|
123
153
|
const base = signInBase(d.authUrl)
|
|
124
154
|
if (!base) return { ok: false, why: "HUNDREDXPROMPT_AUTH_URL must be an http:// or https:// address" }
|
|
125
155
|
const { challenge } = await pkce()
|
|
@@ -145,35 +175,37 @@ export async function browserSignIn(d: SignInDeps): Promise<SignIn> {
|
|
|
145
175
|
const got = url.searchParams.get("state") ?? ""
|
|
146
176
|
const error = url.searchParams.get("error")
|
|
147
177
|
// Only the page this command opened knows the state: anything else is refused and the wait goes on.
|
|
148
|
-
if (!/^[0-9a-f]{64}$/.test(got) || !sameState(got, state)) return page("Sign-in refused",
|
|
178
|
+
if (!/^[0-9a-f]{64}$/.test(got) || !sameState(got, state)) return page("Sign-in refused", `This link was not started by this ${flow.command}. Start again from your terminal.`, 400)
|
|
149
179
|
if (error) {
|
|
150
180
|
settle({ ok: false, why: `the sign-in page said: ${error.slice(0, 200)}` })
|
|
151
181
|
return page("Sign-in failed", `${error.slice(0, 200)}. Go back to your terminal.`, 400)
|
|
152
182
|
}
|
|
153
|
-
const c =
|
|
183
|
+
const c = flow.accept(url.searchParams.get("token") ?? "")
|
|
154
184
|
if (!c.ok) {
|
|
155
185
|
settle({ ok: false, why: c.why })
|
|
156
186
|
return page("Sign-in failed", "No usable key came back. Go back to your terminal.", 400)
|
|
157
187
|
}
|
|
158
188
|
const email = url.searchParams.get("email") || c.email || undefined
|
|
159
189
|
settle({ ok: true, key: c.key, ...(email ? { email } : {}), via: "browser" })
|
|
160
|
-
|
|
190
|
+
const done = flow.done(email)
|
|
191
|
+
return page(done.title, done.body)
|
|
161
192
|
},
|
|
162
193
|
})
|
|
163
|
-
const
|
|
194
|
+
const extra = Object.entries(flow.params ?? {}).map(([k, v]) => `&${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("")
|
|
195
|
+
const url = `${base}${flow.path}?port=${server.port}&state=${state}&challenge=${challenge}${extra}`
|
|
164
196
|
const stopAsking = new AbortController()
|
|
165
197
|
const timer = setTimeout(() => settle({ ok: false, why: `no sign-in came back within ${Math.round((d.timeoutMs ?? TIMEOUT_MS) / 60_000) || 1} minutes` }), d.timeoutMs ?? TIMEOUT_MS)
|
|
166
198
|
try {
|
|
167
|
-
if (d.browser === false) d.say(`Open this address in a browser to
|
|
199
|
+
if (d.browser === false) d.say(`Open this address in a browser to ${flow.opening}:\n ${url}`)
|
|
168
200
|
else {
|
|
169
|
-
d.say(`Opening your browser to
|
|
201
|
+
d.say(`Opening your browser to ${flow.opening}:\n ${url}`)
|
|
170
202
|
try {
|
|
171
203
|
;(d.open ?? openInBrowser)(url)
|
|
172
204
|
} catch {}
|
|
173
205
|
}
|
|
174
206
|
if (d.ask) {
|
|
175
207
|
d.say(
|
|
176
|
-
|
|
208
|
+
`Waiting for the browser. Signing in on another machine (SSH)? ${flow.paste}, then press Enter (Escape cancels).`,
|
|
177
209
|
)
|
|
178
210
|
// The paste runs alongside the browser: whichever comes first wins. A bad paste asks again; Escape ends the sign-in
|
|
179
211
|
// (an abandoned hidden prompt would leave the terminal echoing whatever is pasted next, and the shell reading it).
|
|
@@ -183,7 +215,7 @@ export async function browserSignIn(d: SignInDeps): Promise<SignIn> {
|
|
|
183
215
|
if (settled) return
|
|
184
216
|
if (pasted === undefined) return settle({ ok: false, why: "sign-in cancelled" })
|
|
185
217
|
if (!pasted.trim()) continue
|
|
186
|
-
const c = fromPaste(pasted.trim(), state)
|
|
218
|
+
const c = fromPaste(pasted.trim(), state, flow)
|
|
187
219
|
if (c.ok) return settle({ ok: true, key: c.key, ...(c.email ? { email: c.email } : {}), via: "code" })
|
|
188
220
|
if ("fatal" in c) return settle({ ok: false, why: c.why })
|
|
189
221
|
d.say(` ${c.why}`)
|
|
@@ -207,7 +239,7 @@ export async function browserSignIn(d: SignInDeps): Promise<SignIn> {
|
|
|
207
239
|
* 127.0.0.1 on the wrong machine, and the address it could not open holds the key). An address must carry this sign-in's
|
|
208
240
|
* state, so one from another sign-in, or one made up, is refused.
|
|
209
241
|
*/
|
|
210
|
-
export function fromPaste(pasted: string, state: string): { ok: true; key: string; email?: string } | { ok: false; why: string } | { ok: false; why: string; fatal: true } {
|
|
242
|
+
export function fromPaste(pasted: string, state: string, flow: BrowserFlow = SIGN_IN_FLOW): { ok: true; key: string; email?: string } | { ok: false; why: string } | { ok: false; why: string; fatal: true } {
|
|
211
243
|
if (/^https?:\/\//i.test(pasted)) {
|
|
212
244
|
let u: URL
|
|
213
245
|
try {
|
|
@@ -220,12 +252,12 @@ export function fromPaste(pasted: string, state: string): { ok: true; key: strin
|
|
|
220
252
|
if (!/^[0-9a-f]{64}$/.test(got) || !sameState(got, state)) return { ok: false, why: "that address is from another sign-in: paste the one from this sign-in" }
|
|
221
253
|
const error = u.searchParams.get("error")
|
|
222
254
|
if (error) return { ok: false, why: `the sign-in page said: ${error.slice(0, 200)}`, fatal: true }
|
|
223
|
-
const c =
|
|
255
|
+
const c = flow.accept(u.searchParams.get("token") ?? "")
|
|
224
256
|
if (!c.ok) return c
|
|
225
257
|
const email = u.searchParams.get("email") || c.email
|
|
226
258
|
return { ok: true, key: c.key, ...(email ? { email } : {}) }
|
|
227
259
|
}
|
|
228
|
-
return checkCredential(pasted)
|
|
260
|
+
return flow === SIGN_IN_FLOW ? checkCredential(pasted) : flow.accept(pasted)
|
|
229
261
|
}
|
|
230
262
|
|
|
231
263
|
/**
|
package/src/hooks.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { CLI_PATH, selfArgs, stableBun } from "./self.ts"
|
|
2
|
-
import {
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, statSync, unlinkSync } from "node:fs"
|
|
3
3
|
import { dirname, join } from "node:path"
|
|
4
4
|
import { toolLabel } from "./brief.ts"
|
|
5
|
-
import { gitInfo, isDir } from "./fsutil.ts"
|
|
5
|
+
import { gitInfo, isDir, writeAtomic } from "./fsutil.ts"
|
|
6
6
|
import { findHandoff, type Handoff, markConsumed } from "./handoff.ts"
|
|
7
7
|
import { syncInBackground } from "./memory/sync.ts"
|
|
8
|
+
import { withBrainLock } from "./lock.ts"
|
|
8
9
|
|
|
9
10
|
// Session-start hooks: how a pending handoff (handoff.ts) reaches the model in the target tool without the
|
|
10
11
|
// person pasting anything. Each tool runs `<bun> brain/src/cli.ts session hook session-start --tool <tool>`
|
|
@@ -193,19 +194,15 @@ function save(path: string, config: Json, indent: string) {
|
|
|
193
194
|
const real = existsSync(path) ? realpathSync(path) : path
|
|
194
195
|
mkdirSync(dirname(real), { recursive: true })
|
|
195
196
|
if (existsSync(real) && !existsSync(backupOf(path))) copyFileSync(real, backupOf(path))
|
|
196
|
-
const tmp = `${real}.brain-tmp`
|
|
197
197
|
let mode: number | undefined
|
|
198
198
|
try {
|
|
199
199
|
mode = statSync(real).mode & 0o777
|
|
200
200
|
} catch {
|
|
201
201
|
// A new file keeps the default mode.
|
|
202
202
|
}
|
|
203
|
-
//
|
|
204
|
-
// not sit readable by everyone, even for a moment.
|
|
205
|
-
|
|
206
|
-
writeFileSync(tmp, `${JSON.stringify(config, null, indent)}\n`, mode === undefined ? {} : { mode })
|
|
207
|
-
if (mode !== undefined) chmodSync(tmp, mode)
|
|
208
|
-
renameSync(tmp, real)
|
|
203
|
+
// The copy is created with the file's own mode: Claude Code's settings are 0600 and can hold API keys, so they must
|
|
204
|
+
// not sit readable by everyone, even for a moment.
|
|
205
|
+
writeAtomic(real, `${JSON.stringify(config, null, indent)}\n`, mode)
|
|
209
206
|
}
|
|
210
207
|
|
|
211
208
|
interface Pos {
|
|
@@ -256,6 +253,12 @@ export function hookInstalled(tool: string, home: string): boolean {
|
|
|
256
253
|
return Array.isArray(list) && positions(list, spec, id).length > 0 && !spec.off?.(loaded.config)
|
|
257
254
|
}
|
|
258
255
|
|
|
256
|
+
/** Whether `tool` has a session-start hook brain can install, and has run here (its folder exists). */
|
|
257
|
+
export function hookToolHere(tool: string, home: string): boolean {
|
|
258
|
+
const spec = SPECS[toolId(tool)]
|
|
259
|
+
return !!spec && isDir(join(home, spec.dir))
|
|
260
|
+
}
|
|
261
|
+
|
|
259
262
|
export interface HookChange {
|
|
260
263
|
tool: string
|
|
261
264
|
/** The config file changed (or that would be). */
|
|
@@ -287,6 +290,10 @@ function prepare(tool: string, home: string): { id: string; spec: Spec; path: st
|
|
|
287
290
|
* the hook command (`<bun> <cli.ts>`); the rest stays, since that is how uninstall finds the entry.
|
|
288
291
|
*/
|
|
289
292
|
export async function installHooks(tools: string[], home: string, opts: { dryRun?: boolean; command?: string } = {}): Promise<HookChange[]> {
|
|
293
|
+
return opts.dryRun ? installLocked(tools, home, opts) : withBrainLock(home, () => installLocked(tools, home, opts), { what: "installing session hooks" })
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function installLocked(tools: string[], home: string, opts: { dryRun?: boolean; command?: string }): Promise<HookChange[]> {
|
|
290
297
|
const out: HookChange[] = []
|
|
291
298
|
for (const tool of [...new Set(tools)]) {
|
|
292
299
|
const p = prepare(tool, home)
|
|
@@ -333,6 +340,10 @@ export async function installHooks(tools: string[], home: string, opts: { dryRun
|
|
|
333
340
|
|
|
334
341
|
/** Removes exactly what installHooks added. */
|
|
335
342
|
export async function uninstallHooks(tools: string[], home: string, opts: { dryRun?: boolean } = {}): Promise<HookChange[]> {
|
|
343
|
+
return opts.dryRun ? uninstallLocked(tools, home, opts) : withBrainLock(home, () => uninstallLocked(tools, home, opts), { what: "removing session hooks" })
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function uninstallLocked(tools: string[], home: string, opts: { dryRun?: boolean }): Promise<HookChange[]> {
|
|
336
347
|
const out: HookChange[] = []
|
|
337
348
|
for (const tool of [...new Set(tools)]) {
|
|
338
349
|
const p = prepare(tool, home)
|