atom-agent 0.3.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.
- package/CHANGELOG.md +27 -0
- package/LICENSE +21 -0
- package/README.md +214 -0
- package/dist/App.js +2428 -0
- package/dist/adapters.js +926 -0
- package/dist/auth.js +122 -0
- package/dist/cli.js +28 -0
- package/dist/compact.js +277 -0
- package/dist/context-windows.js +112 -0
- package/dist/env-block.js +166 -0
- package/dist/permissions.js +129 -0
- package/dist/providers.js +224 -0
- package/dist/session.js +218 -0
- package/dist/skills.js +283 -0
- package/dist/snapshots.js +243 -0
- package/dist/system.js +22 -0
- package/dist/tools.js +1867 -0
- package/dist/zen.js +1862 -0
- package/package.json +54 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Per-turn environment block (Task 6, plans/tasks.md): grounds every ATOM
|
|
2
|
+
// turn in repo reality — cwd, git branch/status (best-effort), node version,
|
|
3
|
+
// timestamp.
|
|
4
|
+
//
|
|
5
|
+
// Placement: pinned to the SYSTEM message only (suffix to history[0]'s
|
|
6
|
+
// content via withEnvBlock), NEVER into user content. history[0] is the only
|
|
7
|
+
// slot truncateHistory never drops, so the block survives budget trimming.
|
|
8
|
+
// Caching: the App refreshes history[0] once per turn in submit() (before the
|
|
9
|
+
// budget check, so truncation accounts for it) — the loop's up-to-30 POSTs
|
|
10
|
+
// reuse the same history[0], so git is shelled at most once per turn.
|
|
11
|
+
// Failure-silent: missing git / non-repo cwd / timeout → the block shrinks
|
|
12
|
+
// (cwd + node + time only), never throws, never blocks the turn. No new
|
|
13
|
+
// dependencies; one cheap `git status` invocation with a short timeout, and
|
|
14
|
+
// zero shell-outs when `.git` is absent.
|
|
15
|
+
import { execFileSync } from "node:child_process";
|
|
16
|
+
import { existsSync } from "node:fs";
|
|
17
|
+
import * as path from "node:path";
|
|
18
|
+
// Cap for the block itself (~500 chars per the task). The base system prompt
|
|
19
|
+
// (SYSTEM_PROMPT + AGENTS.md overlay) is untouched by this cap.
|
|
20
|
+
export const ENV_BLOCK_CHAR_CAP = 500;
|
|
21
|
+
// Marker identifying a previously pinned block (for idempotent refresh).
|
|
22
|
+
export const ENV_BLOCK_TAG = "[env ";
|
|
23
|
+
// Single git invocation budget: fail fast, never stall the turn.
|
|
24
|
+
const GIT_TIMEOUT_MS = 750;
|
|
25
|
+
// Long Windows cwds would blow the cap alone: keep the identifying tail.
|
|
26
|
+
const CWD_DISPLAY_CAP = 180;
|
|
27
|
+
function shortCwd(cwd) {
|
|
28
|
+
if (cwd.length <= CWD_DISPLAY_CAP)
|
|
29
|
+
return cwd;
|
|
30
|
+
return `…${cwd.slice(cwd.length - (CWD_DISPLAY_CAP - 1))}`;
|
|
31
|
+
}
|
|
32
|
+
// Pure formatter (no I/O): always `cwd + node + time`, plus
|
|
33
|
+
// `branch + status` only when git reported them. Capped to
|
|
34
|
+
// ENV_BLOCK_CHAR_CAP (cwd is pre-truncated so time/node survive the cap).
|
|
35
|
+
export function buildEnvBlock(parts) {
|
|
36
|
+
const cwd = shortCwd(parts.cwd);
|
|
37
|
+
const git = parts.branch !== undefined &&
|
|
38
|
+
parts.branch !== null &&
|
|
39
|
+
parts.branch.length > 0
|
|
40
|
+
? ` branch=${parts.branch} status=${parts.status ?? "unknown"}`
|
|
41
|
+
: "";
|
|
42
|
+
const block = `[env cwd=${cwd}${git} node=${parts.nodeVersion} time=${parts.timestamp}]`;
|
|
43
|
+
return block.length > ENV_BLOCK_CHAR_CAP
|
|
44
|
+
? `${block.slice(0, ENV_BLOCK_CHAR_CAP - 1)}]`
|
|
45
|
+
: block;
|
|
46
|
+
}
|
|
47
|
+
// One cheap `git status --branch --porcelain=v1`: branch from the `##` line,
|
|
48
|
+
// dirtiness from the remaining file lines. Null on ANY failure (no repo, no
|
|
49
|
+
// git binary, timeout) — the caller shrinks the block instead.
|
|
50
|
+
export function getGitInfo(cwd) {
|
|
51
|
+
try {
|
|
52
|
+
if (!existsSync(path.join(cwd, ".git")))
|
|
53
|
+
return null;
|
|
54
|
+
const out = execFileSync("git", ["status", "--branch", "--porcelain=v1"], {
|
|
55
|
+
cwd,
|
|
56
|
+
timeout: GIT_TIMEOUT_MS,
|
|
57
|
+
encoding: "utf8",
|
|
58
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
59
|
+
});
|
|
60
|
+
const text = typeof out === "string" ? out : String(out ?? "");
|
|
61
|
+
const lines = text.split("\n");
|
|
62
|
+
const head = (lines[0] ?? "").trim();
|
|
63
|
+
if (!head.startsWith("## "))
|
|
64
|
+
return null;
|
|
65
|
+
const rest = head.slice(3).trim();
|
|
66
|
+
let branch;
|
|
67
|
+
if (rest.startsWith("No commits yet on ")) {
|
|
68
|
+
branch = rest.slice("No commits yet on ".length).split(" ")[0] ?? "";
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
branch = rest.split("...")[0]?.split(" ")[0] ?? "";
|
|
72
|
+
}
|
|
73
|
+
branch = branch.trim().slice(0, 64);
|
|
74
|
+
if (!branch)
|
|
75
|
+
return null;
|
|
76
|
+
let changed = 0;
|
|
77
|
+
for (let i = 1; i < lines.length; i++) {
|
|
78
|
+
if ((lines[i] ?? "").trim().length > 0)
|
|
79
|
+
changed += 1;
|
|
80
|
+
}
|
|
81
|
+
return { branch, status: changed === 0 ? "clean" : `dirty:${changed}` };
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Gather + format for a cwd (default: process cwd). NEVER throws: every piece
|
|
88
|
+
// is best-effort and the block shrinks to what was available.
|
|
89
|
+
export function getEnvBlock(cwd = process.cwd()) {
|
|
90
|
+
try {
|
|
91
|
+
let dir = cwd;
|
|
92
|
+
try {
|
|
93
|
+
if (!dir)
|
|
94
|
+
dir = process.cwd();
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
dir = ".";
|
|
98
|
+
}
|
|
99
|
+
let nodeVersion = "unknown";
|
|
100
|
+
try {
|
|
101
|
+
nodeVersion = process.version ?? "unknown";
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// keep fallback
|
|
105
|
+
}
|
|
106
|
+
let timestamp = "";
|
|
107
|
+
try {
|
|
108
|
+
timestamp = new Date().toISOString();
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
timestamp = String(Date.now());
|
|
112
|
+
}
|
|
113
|
+
let git = null;
|
|
114
|
+
try {
|
|
115
|
+
git = getGitInfo(dir);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
git = null;
|
|
119
|
+
}
|
|
120
|
+
return buildEnvBlock({
|
|
121
|
+
cwd: dir,
|
|
122
|
+
branch: git?.branch ?? null,
|
|
123
|
+
status: git?.status ?? null,
|
|
124
|
+
nodeVersion,
|
|
125
|
+
timestamp,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return `[env node=unknown time=${Date.now()}]`;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Remove a previously pinned block (idempotent refresh needs this so blocks
|
|
133
|
+
// never stack across turns). Only strips a TRAILING block — an "[env "
|
|
134
|
+
// anywhere else in the prompt is left alone.
|
|
135
|
+
export function stripEnvBlock(content) {
|
|
136
|
+
const idx = content.lastIndexOf(`\n\n${ENV_BLOCK_TAG}`);
|
|
137
|
+
if (idx < 0)
|
|
138
|
+
return content;
|
|
139
|
+
const tail = content.slice(idx + 2);
|
|
140
|
+
if (!tail.startsWith(ENV_BLOCK_TAG))
|
|
141
|
+
return content;
|
|
142
|
+
if (!tail.trimEnd().endsWith("]"))
|
|
143
|
+
return content;
|
|
144
|
+
return content.slice(0, idx);
|
|
145
|
+
}
|
|
146
|
+
// Pin a fresh block to system content (strips any prior block first, so
|
|
147
|
+
// per-turn refresh is idempotent). NEVER throws — on any failure the input
|
|
148
|
+
// is returned unchanged.
|
|
149
|
+
export function withEnvBlock(systemContent, cwd) {
|
|
150
|
+
try {
|
|
151
|
+
const base = stripEnvBlock(systemContent);
|
|
152
|
+
let block = "";
|
|
153
|
+
try {
|
|
154
|
+
block = getEnvBlock(cwd ?? process.cwd());
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return base;
|
|
158
|
+
}
|
|
159
|
+
if (!block)
|
|
160
|
+
return base;
|
|
161
|
+
return `${base}\n\n${block}`;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return systemContent;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Scoped allow/deny rules for tool approval (ticket 03).
|
|
2
|
+
// Pure module: pattern matching over tool name + arguments. No UI imports.
|
|
3
|
+
//
|
|
4
|
+
// Rule syntax: `tool` or `tool:glob`
|
|
5
|
+
// - `write` matches any write call (tool-only rule)
|
|
6
|
+
// - `bash:npm test*` matches bash calls whose command starts with "npm test"
|
|
7
|
+
// - `write:src/**` matches writes under src/
|
|
8
|
+
// The glob applies to the tool's primary string (the same primary shown in
|
|
9
|
+
// the `⚙` audit line): path for read/write/edit, pattern for glob/grep,
|
|
10
|
+
// command for bash, url/query for webfetch/websearch, taskId for
|
|
11
|
+
// bash_output, question for ask_question. Other tools have no primary string,
|
|
12
|
+
// so only tool-only rules match them.
|
|
13
|
+
// Glob dialect: `*` matches any sequence (including `/` and spaces), `?`
|
|
14
|
+
// matches exactly one char, everything else is literal. Case-sensitive.
|
|
15
|
+
//
|
|
16
|
+
// Effect is decided at the approve() call site (App.tsx): deny is checked
|
|
17
|
+
// first and wins over yolo/session-trust/always/skill grants; allow runs the
|
|
18
|
+
// call without prompting. With no rules the verdict is null and the existing
|
|
19
|
+
// y/a/n + /trust flow is byte-identical. Rules only take effect on
|
|
20
|
+
// approval-gated calls (write/edit/bash) because read-only tools never
|
|
21
|
+
// consult approve() — a rule naming another tool is accepted but inert.
|
|
22
|
+
// Parse one user-typed pattern (`tool` or `tool:glob`). Returns null when the
|
|
23
|
+
// pattern has no usable tool name (empty, whitespace, or a colon first).
|
|
24
|
+
export function parseRuleInput(pattern, kind) {
|
|
25
|
+
const raw = pattern.trim();
|
|
26
|
+
if (raw.length === 0)
|
|
27
|
+
return null;
|
|
28
|
+
const colon = raw.indexOf(":");
|
|
29
|
+
const tool = (colon === -1 ? raw : raw.slice(0, colon)).trim();
|
|
30
|
+
// Tool names are single lowercase tokens; anything else is a typo worth
|
|
31
|
+
// rejecting loudly rather than a rule that silently never matches.
|
|
32
|
+
if (!/^[a-z0-9_-]+$/.test(tool))
|
|
33
|
+
return null;
|
|
34
|
+
let glob = null;
|
|
35
|
+
if (colon !== -1) {
|
|
36
|
+
const rest = raw.slice(colon + 1).trim();
|
|
37
|
+
glob = rest.length > 0 ? rest : null;
|
|
38
|
+
}
|
|
39
|
+
return { kind, tool, glob, pattern: tool + (glob === null ? "" : `:${glob}`) };
|
|
40
|
+
}
|
|
41
|
+
// Glob match: `*` = any sequence, `?` = one char, else literal.
|
|
42
|
+
// Case-sensitive (commands and paths are). Empty pattern matches only "".
|
|
43
|
+
export function matchGlob(pattern, value) {
|
|
44
|
+
let px = 0;
|
|
45
|
+
let vx = 0;
|
|
46
|
+
let star = -1;
|
|
47
|
+
let starVx = 0;
|
|
48
|
+
while (vx < value.length) {
|
|
49
|
+
const p = pattern[px];
|
|
50
|
+
if (px < pattern.length && (p === "?" || p === value[vx])) {
|
|
51
|
+
px += 1;
|
|
52
|
+
vx += 1;
|
|
53
|
+
}
|
|
54
|
+
else if (px < pattern.length && p === "*") {
|
|
55
|
+
star = px;
|
|
56
|
+
starVx = vx;
|
|
57
|
+
px += 1;
|
|
58
|
+
}
|
|
59
|
+
else if (star !== -1) {
|
|
60
|
+
starVx += 1;
|
|
61
|
+
vx = starVx;
|
|
62
|
+
px = star + 1;
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
while (px < pattern.length && pattern[px] === "*")
|
|
69
|
+
px += 1;
|
|
70
|
+
return px === pattern.length;
|
|
71
|
+
}
|
|
72
|
+
// Primary string for a tool call: the same primary describeToolCall shows in
|
|
73
|
+
// the `⚙` audit line, so what you allow is what you see. Non-string or
|
|
74
|
+
// missing primaries read as "" (a glob can still match "" explicitly, e.g.
|
|
75
|
+
// `*` — a tool-only rule is the clearer way to say "any").
|
|
76
|
+
export function primaryTarget(name, args) {
|
|
77
|
+
const a = args ?? {};
|
|
78
|
+
const str = (v) => (typeof v === "string" ? v : "");
|
|
79
|
+
switch (name) {
|
|
80
|
+
case "read":
|
|
81
|
+
case "write":
|
|
82
|
+
case "edit":
|
|
83
|
+
return str(a["path"]);
|
|
84
|
+
case "glob":
|
|
85
|
+
case "grep":
|
|
86
|
+
return str(a["pattern"]);
|
|
87
|
+
case "bash":
|
|
88
|
+
return str(a["command"]);
|
|
89
|
+
case "bash_output":
|
|
90
|
+
return str(a["taskId"]);
|
|
91
|
+
case "webfetch":
|
|
92
|
+
return str(a["url"]);
|
|
93
|
+
case "websearch":
|
|
94
|
+
return str(a["query"]);
|
|
95
|
+
case "ask_question":
|
|
96
|
+
return str(a["question"]);
|
|
97
|
+
default:
|
|
98
|
+
return "";
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function ruleMatches(rule, name, args) {
|
|
102
|
+
if (rule.tool !== name)
|
|
103
|
+
return false;
|
|
104
|
+
if (rule.glob === null)
|
|
105
|
+
return true;
|
|
106
|
+
return matchGlob(rule.glob, primaryTarget(name, args));
|
|
107
|
+
}
|
|
108
|
+
// First deny match wins (even over allows and over trust/yolo at the call
|
|
109
|
+
// site); otherwise the first allow match auto-approves; otherwise null
|
|
110
|
+
// (fall through to the normal prompt flow).
|
|
111
|
+
export function checkRules(rules, name, args) {
|
|
112
|
+
for (const rule of rules) {
|
|
113
|
+
if (rule.kind === "deny" && ruleMatches(rule, name, args))
|
|
114
|
+
return "deny";
|
|
115
|
+
}
|
|
116
|
+
for (const rule of rules) {
|
|
117
|
+
if (rule.kind === "allow" && ruleMatches(rule, name, args))
|
|
118
|
+
return "allow";
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
export function formatRule(rule) {
|
|
123
|
+
return `${rule.kind === "allow" ? "allow" : "deny"}: ${rule.pattern}`;
|
|
124
|
+
}
|
|
125
|
+
export function formatRules(rules) {
|
|
126
|
+
if (rules.length === 0)
|
|
127
|
+
return "(no rules)";
|
|
128
|
+
return `Rules (${rules.length}):\n${rules.map((r, i) => `${i + 1}. ${formatRule(r)}`).join("\n")}`;
|
|
129
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// Provider registry for Atom (mirrors opencode /connect, manual-key only).
|
|
2
|
+
// No OAuth, no browser flow. Credentials live in ~/.atom/auth.json;
|
|
3
|
+
// env vars win when set (see src/auth.ts).
|
|
4
|
+
//
|
|
5
|
+
// Curated fallback model lists were picked at BUILD time (2026-09-07)
|
|
6
|
+
// from each vendor's docs (tool-capable chat models preferred, 3-6 ids):
|
|
7
|
+
// - opencode-zen: existing FALLBACK_MODELS in src/zen.ts (verified from
|
|
8
|
+
// https://opencode.ai/docs/zen). Re-listed here for the picker.
|
|
9
|
+
// - openai: https://platform.openai.com/docs/models — flagship chat models
|
|
10
|
+
// with function calling (GPT-6 Astra, GPT-5.6 Sol/Terra/Luna).
|
|
11
|
+
// - anthropic: https://docs.anthropic.com/en/docs/about-claude/models —
|
|
12
|
+
// Claude Sonnet/Opus/Haiku with tool use (Messages API).
|
|
13
|
+
// - deepseek: https://api-docs.deepseek.com/quick_start/pricing —
|
|
14
|
+
// deepseek-v4-flash / deepseek-v4-pro / deepseek-v4-flash-vision-exp
|
|
15
|
+
// (all list Tool Calls support; OpenAI-compatible base https://api.deepseek.com).
|
|
16
|
+
// - mistral: https://docs.mistral.ai/inference/models — generalist
|
|
17
|
+
// tool-capable models via *-latest aliases (large/medium/small + nemo).
|
|
18
|
+
// - google-gemini: https://ai.google.dev/gemini-api/docs/models —
|
|
19
|
+
// Gemini 2.5/2.0/1.5 Flash/Pro with function calling.
|
|
20
|
+
// - openai-compatible: generic OpenAI-shape ids (custom baseURL); the live
|
|
21
|
+
// /models list is authoritative, these are just offline placeholders.
|
|
22
|
+
export const DEFAULT_PROVIDER = "opencode-zen";
|
|
23
|
+
export const PROVIDERS = [
|
|
24
|
+
{
|
|
25
|
+
id: "opencode-zen",
|
|
26
|
+
name: "OpenCode Zen",
|
|
27
|
+
kind: "openai-chat",
|
|
28
|
+
chatEndpoint: "https://opencode.ai/zen/v1/chat/completions",
|
|
29
|
+
consoleURL: "https://opencode.ai/auth",
|
|
30
|
+
envVars: ["OPENCODE_ZEN_API_KEY"],
|
|
31
|
+
// Task 5: strong tool-reliable default (live-list + docs verified
|
|
32
|
+
// 2026-09-08, see DEFAULT_MODEL in src/zen.ts). Free big-pickle stays
|
|
33
|
+
// listed as a fallback, selectable via /model. kimi-k2.6 / minimax-m2.7
|
|
34
|
+
// replace kimi-k2.5 / minimax-m2.5 (both deprecated upstream 2026-08-05).
|
|
35
|
+
defaultModel: "deepseek-v4-pro",
|
|
36
|
+
fallbackModels: [
|
|
37
|
+
"deepseek-v4-pro",
|
|
38
|
+
"kimi-k2.6",
|
|
39
|
+
"glm-5.2",
|
|
40
|
+
"minimax-m2.7",
|
|
41
|
+
"big-pickle",
|
|
42
|
+
],
|
|
43
|
+
notes: "OpenAI-compatible chat/completions. reasoning_effort only here.",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "openai",
|
|
47
|
+
name: "OpenAI",
|
|
48
|
+
kind: "openai-chat",
|
|
49
|
+
chatEndpoint: "https://api.openai.com/v1/chat/completions",
|
|
50
|
+
consoleURL: "https://platform.openai.com/api-keys",
|
|
51
|
+
envVars: ["OPENAI_API_KEY"],
|
|
52
|
+
defaultModel: "gpt-5.6-terra",
|
|
53
|
+
fallbackModels: [
|
|
54
|
+
"gpt-6-astra",
|
|
55
|
+
"gpt-5.6-sol",
|
|
56
|
+
"gpt-5.6-terra",
|
|
57
|
+
"gpt-5.6-luna",
|
|
58
|
+
],
|
|
59
|
+
notes: "OpenAI-compatible chat/completions. reasoning_effort never sent.",
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: "anthropic",
|
|
63
|
+
name: "Anthropic",
|
|
64
|
+
kind: "anthropic-messages",
|
|
65
|
+
chatEndpoint: "https://api.anthropic.com/v1/messages",
|
|
66
|
+
consoleURL: "https://console.anthropic.com/settings/keys",
|
|
67
|
+
envVars: ["ANTHROPIC_API_KEY"],
|
|
68
|
+
defaultModel: "claude-sonnet-4-5",
|
|
69
|
+
fallbackModels: [
|
|
70
|
+
"claude-sonnet-4-5",
|
|
71
|
+
"claude-opus-4-1",
|
|
72
|
+
"claude-3-5-sonnet-20241022",
|
|
73
|
+
"claude-3-5-haiku-20241022",
|
|
74
|
+
],
|
|
75
|
+
notes: "Messages API with tool_use blocks. max_tokens 4096.",
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: "deepseek",
|
|
79
|
+
name: "DeepSeek",
|
|
80
|
+
kind: "openai-chat",
|
|
81
|
+
chatEndpoint: "https://api.deepseek.com/chat/completions",
|
|
82
|
+
consoleURL: "https://platform.deepseek.com/api_keys",
|
|
83
|
+
envVars: ["DEEPSEEK_API_KEY"],
|
|
84
|
+
defaultModel: "deepseek-chat",
|
|
85
|
+
fallbackModels: [
|
|
86
|
+
"deepseek-chat",
|
|
87
|
+
"deepseek-reasoner",
|
|
88
|
+
"deepseek-v4-flash",
|
|
89
|
+
"deepseek-v4-pro",
|
|
90
|
+
],
|
|
91
|
+
notes: "OpenAI-compatible (no /v1 prefix). reasoning_effort never sent.",
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: "mistral",
|
|
95
|
+
name: "Mistral",
|
|
96
|
+
kind: "openai-chat",
|
|
97
|
+
chatEndpoint: "https://api.mistral.ai/v1/chat/completions",
|
|
98
|
+
consoleURL: "https://console.mistral.ai/api-keys",
|
|
99
|
+
envVars: ["MISTRAL_API_KEY"],
|
|
100
|
+
defaultModel: "mistral-medium-latest",
|
|
101
|
+
fallbackModels: [
|
|
102
|
+
"mistral-large-latest",
|
|
103
|
+
"mistral-medium-latest",
|
|
104
|
+
"mistral-small-latest",
|
|
105
|
+
"open-mistral-nemo",
|
|
106
|
+
],
|
|
107
|
+
notes: "OpenAI-compatible chat/completions. reasoning_effort never sent.",
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: "google-gemini",
|
|
111
|
+
name: "Google Gemini",
|
|
112
|
+
kind: "gemini-generate",
|
|
113
|
+
// {model} is interpolated per POST; see adapters.geminiChatUrl().
|
|
114
|
+
chatEndpoint: "https://generativelanguage.googleapis.com/v1beta/models",
|
|
115
|
+
consoleURL: "https://aistudio.google.com/apikey",
|
|
116
|
+
envVars: ["GEMINI_API_KEY", "GOOGLE_API_KEY"],
|
|
117
|
+
defaultModel: "gemini-2.5-flash",
|
|
118
|
+
fallbackModels: [
|
|
119
|
+
"gemini-2.5-pro",
|
|
120
|
+
"gemini-2.5-flash",
|
|
121
|
+
"gemini-2.0-flash",
|
|
122
|
+
"gemini-1.5-flash",
|
|
123
|
+
],
|
|
124
|
+
notes: "streamGenerateContent SSE; :generateContent fallback.",
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
id: "openai-compatible",
|
|
128
|
+
name: "OpenAI-compatible (custom)",
|
|
129
|
+
kind: "openai-chat",
|
|
130
|
+
chatEndpoint: undefined,
|
|
131
|
+
consoleURL: "",
|
|
132
|
+
envVars: [],
|
|
133
|
+
defaultModel: "gpt-3.5-turbo",
|
|
134
|
+
fallbackModels: [
|
|
135
|
+
"gpt-3.5-turbo",
|
|
136
|
+
"gpt-4o",
|
|
137
|
+
"llama-3.1-70b-versatile",
|
|
138
|
+
"mixtral-8x7b-32768",
|
|
139
|
+
],
|
|
140
|
+
notes: "Stored baseURL + stored key only. Live /models authoritative.",
|
|
141
|
+
},
|
|
142
|
+
];
|
|
143
|
+
const BY_ID = Object.fromEntries(PROVIDERS.map((p) => [p.id, p]));
|
|
144
|
+
export function getProvider(id) {
|
|
145
|
+
return BY_ID[id];
|
|
146
|
+
}
|
|
147
|
+
export function isProviderId(id) {
|
|
148
|
+
return getProvider(id) !== undefined;
|
|
149
|
+
}
|
|
150
|
+
export function providerKind(id) {
|
|
151
|
+
return getProvider(id).kind;
|
|
152
|
+
}
|
|
153
|
+
// Display label for errors/status (e.g. "Anthropic HTTP 401").
|
|
154
|
+
export function providerLabel(id) {
|
|
155
|
+
const def = getProvider(id);
|
|
156
|
+
if (!def)
|
|
157
|
+
return String(id);
|
|
158
|
+
if (id === "opencode-zen")
|
|
159
|
+
return "Zen";
|
|
160
|
+
if (id === "openai-compatible")
|
|
161
|
+
return "Provider";
|
|
162
|
+
return def.name;
|
|
163
|
+
}
|
|
164
|
+
// Normalize a custom baseURL: trim whitespace/trailing slashes.
|
|
165
|
+
export function normalizeBaseURL(raw) {
|
|
166
|
+
return raw.trim().replace(/\/+$/, "");
|
|
167
|
+
}
|
|
168
|
+
// openai-compatible chat endpoint: stored baseURL + /chat/completions
|
|
169
|
+
// appended iff missing, trailing slashes trimmed.
|
|
170
|
+
export function openaiCompatibleChatEndpoint(baseURL) {
|
|
171
|
+
const base = normalizeBaseURL(baseURL);
|
|
172
|
+
return base.endsWith("/chat/completions")
|
|
173
|
+
? base
|
|
174
|
+
: `${base}/chat/completions`;
|
|
175
|
+
}
|
|
176
|
+
// Chat endpoint for a provider (openai-compatible needs stored baseURL).
|
|
177
|
+
export function chatEndpointFor(id, storedBaseURL) {
|
|
178
|
+
const def = getProvider(id);
|
|
179
|
+
if (id === "openai-compatible") {
|
|
180
|
+
return openaiCompatibleChatEndpoint(storedBaseURL ?? "");
|
|
181
|
+
}
|
|
182
|
+
return def.chatEndpoint ?? "";
|
|
183
|
+
}
|
|
184
|
+
// Derive the models URL from a chat/completions endpoint
|
|
185
|
+
// (mirrors zen.modelsUrl for OpenAI-kind providers).
|
|
186
|
+
export function modelsUrlForEndpoint(endpoint) {
|
|
187
|
+
const suffix = "/chat/completions";
|
|
188
|
+
if (endpoint.endsWith(suffix)) {
|
|
189
|
+
return endpoint.slice(0, -suffix.length) + "/models";
|
|
190
|
+
}
|
|
191
|
+
return endpoint.replace(/\/+$/, "") + "/models";
|
|
192
|
+
}
|
|
193
|
+
// Models (validation/list) URL per provider.
|
|
194
|
+
export function modelsUrlForProvider(id, storedBaseURL) {
|
|
195
|
+
if (id === "anthropic")
|
|
196
|
+
return "https://api.anthropic.com/v1/models";
|
|
197
|
+
if (id === "google-gemini")
|
|
198
|
+
return "https://generativelanguage.googleapis.com/v1beta/models";
|
|
199
|
+
return modelsUrlForEndpoint(chatEndpointFor(id, storedBaseURL));
|
|
200
|
+
}
|
|
201
|
+
// Mask a key for display: "…1234". Never the full key.
|
|
202
|
+
export function maskKey(key) {
|
|
203
|
+
if (!key)
|
|
204
|
+
return "(no key)";
|
|
205
|
+
const last4 = key.slice(-4);
|
|
206
|
+
return `…${last4}`;
|
|
207
|
+
}
|
|
208
|
+
// Validate a custom baseURL: must be http(s). Returns error or null.
|
|
209
|
+
export function validateBaseURL(raw) {
|
|
210
|
+
const s = raw.trim();
|
|
211
|
+
if (!s)
|
|
212
|
+
return "baseURL must be a non-empty string";
|
|
213
|
+
let u;
|
|
214
|
+
try {
|
|
215
|
+
u = new URL(s);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return `invalid URL: ${s}`;
|
|
219
|
+
}
|
|
220
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") {
|
|
221
|
+
return `unsupported URL scheme (only http/https allowed): ${u.protocol}`;
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|