@elliotsystems/eai 0.1.4 → 0.1.5

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.
@@ -1,27 +1,70 @@
1
1
  /**
2
- * Elliot token read + refresh — the credential half of the proxy provider.
2
+ * Reads `~/.elliot/config.json` — written by `eai setup`/`elliot-ai setup`.
3
3
  *
4
- * Ported (trimmed, not imported) from `elliot-cli/src/auth/refresh.ts` and
5
- * `elliot-cli/src/config.ts`: same `~/.elliot/config.json` shape, same
6
- * refresh endpoint contract (`POST {backend_url}/auth/refresh` ->
7
- * `{access_token, expires_in_seconds}`), same "never throw" discipline. This
8
- * file exists inside `opencode-plugin` rather than importing the CLI package
9
- * because the plugin ships and is loaded independently of `elliot-cli` — see
10
- * `docs/superpowers/plans/2026-09-03-eai-proxy-auth.md`.
4
+ * Used two ways: `readElliotConfig() !== null` is "is the user logged in at
5
+ * all" (the proxy-provider config hook is a no-op otherwise); and its
6
+ * `litellm_base_url`/`litellm_master_key` fields are the LLM provider's
7
+ * connection info directly.
11
8
  *
12
- * `freshToken` is the only export the proxy provider's `auth.loader`/`fetch`
13
- * needs: it never throws, so a stale/missing/corrupt config degrades to "no
14
- * token" (the proxy 401s) rather than crashing OpenCode's model call.
9
+ * Formerly also home to `freshToken`/`isExpired` — a self-refreshing JWT
10
+ * mechanism for the per-instance credential `/llm-proxy` used to validate.
11
+ * Removed along with `/llm-proxy`: the LiteLLM gateway this now points at
12
+ * authenticates with ONE static, shared `litellm_master_key` (LiteLLM is
13
+ * deliberately DB-less — no per-tenant/per-instance credential is possible
14
+ * without one), which never expires and so never needs refreshing.
15
15
  */
16
16
  import * as fs from "node:fs"
17
17
  import * as os from "node:os"
18
18
  import * as path from "node:path"
19
19
 
20
+ /**
21
+ * One LiteLLM credential this person can use from OpenCode — their own
22
+ * personal key, or a team's shared key (Phase 4, docs/LITELLM_GATEWAY.md
23
+ * §14 / PRD PO-4/4.3/4.4). `provider_id` is the OpenCode provider id
24
+ * `configHook` registers it under directly (e.g. `elliot` for the personal
25
+ * default, `elliot-team-<slug>` per team) — computed backend-side
26
+ * (`onboarding.py`), not derived here, so naming stays in one place.
27
+ */
28
+ export interface ElliotCredential {
29
+ provider_id: string
30
+ /** Shown in OpenCode's own model picker, e.g. "Elliot (Personal)" or
31
+ * "Elliot — Team Platform". */
32
+ label: string
33
+ api_key: string
34
+ /** Present for a team credential, absent for the personal one. */
35
+ team_id?: string
36
+ /**
37
+ * This credential's own team's Phase 4 model restriction
38
+ * (docs/LITELLM_GATEWAY.md §14), or absent/null for unrestricted — always
39
+ * absent on the personal credential, which has no team and so no
40
+ * restriction concept. LiteLLM gateway audit finding #5: each provider
41
+ * `configHook` registers must be filtered by ITS OWN credential's
42
+ * restriction, not a shared list — a user on both a restricted and an
43
+ * unrestricted team must not have one leak into the other.
44
+ */
45
+ allowed_models?: string[] | null
46
+ }
47
+
20
48
  export interface ElliotConfig {
21
49
  jwt_token?: string
22
- refresh_token?: string
23
50
  backend_url?: string
24
- access_token_expires_at?: string
51
+ litellm_base_url?: string
52
+ litellm_master_key?: string
53
+ /**
54
+ * Every credential this person can use, personal key first (Phase 4).
55
+ * Absent on a config written by an older `eai setup` (or a backend that
56
+ * predates this field) — `configHook` falls back to the single
57
+ * `litellm_master_key` provider in that case, so an old config keeps
58
+ * working unchanged rather than losing LLM access outright.
59
+ */
60
+ credentials?: ElliotCredential[]
61
+ /**
62
+ * The single scoped team's own Phase 4 model restriction, when
63
+ * `credentials` is absent and the legacy `litellm_master_key` fallback
64
+ * provider is registered instead — same field, same meaning as
65
+ * `ElliotCredential.allowed_models`, just for the one-provider path.
66
+ */
67
+ allowed_models?: string[] | null
25
68
  [k: string]: unknown
26
69
  }
27
70
 
@@ -40,94 +83,3 @@ export function readElliotConfig(): ElliotConfig | null {
40
83
  return null
41
84
  }
42
85
  }
43
-
44
- /**
45
- * Persist the config, owner-only (0600) — this file holds a live bearer
46
- * token. Creates `~/.elliot` (0700) if it doesn't exist yet.
47
- */
48
- export function writeElliotConfig(cfg: ElliotConfig): void {
49
- const configPath = elliotConfigPath()
50
- const configDir = path.dirname(configPath)
51
- if (!fs.existsSync(configDir)) {
52
- fs.mkdirSync(configDir, { recursive: true, mode: 0o700 })
53
- }
54
- fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2), { mode: 0o600 })
55
- fs.chmodSync(configPath, 0o600)
56
- }
57
-
58
- /** `exp` (seconds since epoch) from a JWT's middle segment, or null. */
59
- function decodeJwtExp(token: string): number | null {
60
- try {
61
- const parts = token.split(".")
62
- if (parts.length < 2) return null
63
- const payload = JSON.parse(Buffer.from(parts[1], "base64").toString("utf-8"))
64
- return typeof payload?.exp === "number" ? payload.exp : null
65
- } catch {
66
- return null
67
- }
68
- }
69
-
70
- const REFRESH_SKEW_MS = 60_000
71
-
72
- /**
73
- * True if the access token is expired or within `REFRESH_SKEW_MS` of
74
- * expiring. Prefers the stored `access_token_expires_at`; falls back to
75
- * decoding `jwt_token`'s `exp` claim; true (treat as expired -> refresh) when
76
- * neither is decodable.
77
- */
78
- export function isExpired(cfg: ElliotConfig, nowMs = Date.now()): boolean {
79
- if (cfg.access_token_expires_at) {
80
- const expiresAtMs = new Date(cfg.access_token_expires_at).getTime()
81
- if (Number.isFinite(expiresAtMs)) {
82
- return expiresAtMs - REFRESH_SKEW_MS <= nowMs
83
- }
84
- }
85
- const exp = cfg.jwt_token ? decodeJwtExp(cfg.jwt_token) : null
86
- if (exp === null) return true
87
- return exp * 1000 - REFRESH_SKEW_MS <= nowMs
88
- }
89
-
90
- interface RefreshResponse {
91
- access_token: string
92
- expires_in_seconds: number
93
- }
94
-
95
- interface FreshTokenDeps {
96
- fetchImpl?: typeof fetch
97
- write?: (cfg: ElliotConfig) => void
98
- }
99
-
100
- /**
101
- * A valid access token, refreshing + persisting when expired and a
102
- * `refresh_token`/`backend_url` are present. NEVER throws: any failure
103
- * (network, non-2xx, malformed response) falls back to the existing
104
- * `jwt_token` — the proxy 401s on a stale token rather than this crashing the
105
- * model call.
106
- */
107
- export async function freshToken(
108
- cfg: ElliotConfig,
109
- deps?: FreshTokenDeps,
110
- ): Promise<string | null> {
111
- try {
112
- if (cfg.jwt_token && !isExpired(cfg)) return cfg.jwt_token
113
- if (!cfg.refresh_token || !cfg.backend_url) return cfg.jwt_token ?? null
114
-
115
- const fetchImpl = deps?.fetchImpl ?? fetch
116
- const write = deps?.write ?? writeElliotConfig
117
-
118
- const response = await fetchImpl(`${cfg.backend_url}/auth/refresh`, {
119
- method: "POST",
120
- headers: { "Content-Type": "application/json" },
121
- body: JSON.stringify({ refresh_token: cfg.refresh_token }),
122
- })
123
- if (!response.ok) return cfg.jwt_token ?? null
124
-
125
- const data = (await response.json()) as RefreshResponse
126
- cfg.jwt_token = data.access_token
127
- cfg.access_token_expires_at = new Date(Date.now() + data.expires_in_seconds * 1000).toISOString()
128
- write(cfg)
129
- return data.access_token
130
- } catch {
131
- return cfg.jwt_token ?? null
132
- }
133
- }
@@ -20,8 +20,7 @@
20
20
  * `.opencode/plugin/` file re-export.
21
21
  */
22
22
  import { isElliotKnowledgeTool, parseOptions, shellVerdict } from "./policy.js"
23
- import { ELLIOT_PROVIDER_ID, configHook, makeAuthHook } from "./proxy-provider.js"
24
- import { readElliotConfig } from "./elliot-auth.js"
23
+ import { configHook } from "./proxy-provider.js"
25
24
  import { formatScope, scopeForWorktree } from "./scope.js"
26
25
  import { applyElliotIdentity } from "./identity.js"
27
26
 
@@ -87,25 +86,6 @@ export const ElliotPlugin: Plugin = async (input, rawOptions) => {
87
86
  // the one source the model cannot influence.
88
87
  const worktree = input.worktree || input.directory
89
88
 
90
- // Seed a placeholder auth entry for the `elliot` provider so the host's
91
- // auth loader fires at all (`provider.ts:1584` skips the loader entirely
92
- // when nothing is stored for its provider id — see Task-1 findings). The
93
- // value itself is never the credential that reaches the wire; the auth
94
- // loader's `fetch` attaches the live bearer token per request. Guarded so
95
- // a client-RPC failure never breaks plugin init, and skipped outright when
96
- // the user isn't logged into Elliot.
97
- if (readElliotConfig()) {
98
- try {
99
- await input.client?.auth?.set({
100
- path: { id: ELLIOT_PROVIDER_ID },
101
- body: { type: "api", key: "elliot-managed" },
102
- })
103
- } catch {
104
- // Never break plugin init on a seeding failure — the auth loader
105
- // simply won't fire this session, same as the no-login path.
106
- }
107
- }
108
-
109
89
  return {
110
90
  /**
111
91
  * One handler, two jobs — a JS object literal may carry only one
@@ -195,13 +175,6 @@ export const ElliotPlugin: Plugin = async (input, rawOptions) => {
195
175
  config: async (c) => {
196
176
  configHook(c)
197
177
  },
198
-
199
- /**
200
- * Self-refreshing auth loader for the `elliot` provider — see
201
- * `proxy-provider.ts`. Only fires once the seeding above has stored an
202
- * auth entry for `ELLIOT_PROVIDER_ID`.
203
- */
204
- auth: makeAuthHook(),
205
178
  }
206
179
  }
207
180
 
@@ -24,7 +24,134 @@ export function sanitizeKey(value: string): string {
24
24
  /** The backend tool behind every Elliot knowledge lookup. */
25
25
  export const KNOWLEDGE_TOOL = "knowledge_search"
26
26
 
27
- /** The agent-visible key for `KNOWLEDGE_TOOL` on a given MCP server. */
27
+ /** The agent-visible key for `KNOWLEDGE_TOOL` on a given MCP server.
28
+ *
29
+ * Still the PRE-GATEWAY spelling, and still correct for a runtime wired
30
+ * straight to Elliot. It is no longer the only spelling that arrives — see
31
+ * `toCanonicalTool`.
32
+ */
28
33
  export function knowledgeToolKey(serverName: string): string {
29
34
  return `${sanitizeKey(serverName)}_${KNOWLEDGE_TOOL}`
30
35
  }
36
+
37
+ /**
38
+ * LiteLLM joins its server alias to the tool name with a HYPHEN; OpenCode
39
+ * joins its own server key with an UNDERSCORE. Both appear in one wire name
40
+ * and they are not interchangeable.
41
+ *
42
+ * The SEPARATOR is LiteLLM's composition rule and is fixed. The ALIAS is
43
+ * configuration — it is the `mcp_servers` key in `deploy/litellm/config.yaml`
44
+ * — and this module no longer owns a copy of it. It used to:
45
+ *
46
+ * export const GATEWAY_SERVER_ALIAS = "elliot"
47
+ *
48
+ * restated from the backend and "kept in step by a test". That is four copies
49
+ * of one literal across two languages, and the failure when they diverge is
50
+ * silent: an unrecognised tool name is one whose repo scope is never injected,
51
+ * so the agent answers tenant-wide and says nothing about it.
52
+ *
53
+ * The alias now arrives as a plugin option, served by the backend from
54
+ * `litellm_mcp_server_alias` (`routers/well_known.py`).
55
+ */
56
+ const GATEWAY_SEPARATOR = "-"
57
+
58
+ /**
59
+ * The canonical Elliot tool name behind an agent-visible key.
60
+ *
61
+ * Runtime traffic now reaches Elliot through LiteLLM's MCP gateway, and each
62
+ * hop adds its own namespace. All four spellings below are real, depending on
63
+ * whether the runtime is behind the gateway and how the host composed the key:
64
+ *
65
+ * elliot_elliot-knowledge_search OpenCode prefix + gateway prefix
66
+ * elliot-knowledge_search gateway prefix only
67
+ * elliot_knowledge_search OpenCode prefix only (pre-gateway)
68
+ * knowledge_search bare/canonical
69
+ *
70
+ * Stripping is DETERMINISTIC and bounded: at most one server prefix, then at
71
+ * most one gateway prefix, each only from the front. Never a suffix or
72
+ * substring match — that is what stops a server called `not-elliot` from
73
+ * collecting scope hints meant for ours, and it is why the configured server
74
+ * names are required rather than inferred.
75
+ *
76
+ * Idempotent: feeding a canonical name back in returns it unchanged.
77
+ */
78
+ /**
79
+ * The canonical tool name when the gateway alias is NOT knowable.
80
+ *
81
+ * For the TUI only. The TUI plugin entry (`tui: TuiPlugin = async (api)`)
82
+ * receives no options — the host hands it a UI api and nothing else — so it
83
+ * cannot be told the configured alias, and inventing a config channel for a
84
+ * sidebar would be a worse answer than this one.
85
+ *
86
+ * So it matches on STRUCTURE rather than on a specific alias: strip one
87
+ * configured server prefix, then allow ONE unknown `<something>-` layer.
88
+ *
89
+ * THIS IS NOT A FALLBACK TO A GUESSED ALIAS, and it does not weaken the
90
+ * anti-substring property that `toCanonicalTool` exists to hold. That property
91
+ * comes from the SERVER prefix, which the TUI does know (`api.state.mcp()`
92
+ * supplies it) and which is still required here. By the time an unknown
93
+ * gateway layer is considered, the key has already been proven to belong to a
94
+ * configured Elliot server — `not-elliot_knowledge_search` never reaches that
95
+ * line.
96
+ *
97
+ * Confined to display. Nothing authorizes on this: the sidebar decides which
98
+ * transcript rows to render, and the cost of a wrong answer is a missing or
99
+ * extra row, not a wrong scope. Authorization uses `toCanonicalTool` with the
100
+ * configured alias, on the backend, where the alias is known.
101
+ */
102
+ export function toCanonicalToolUnderAnyGateway(
103
+ toolKey: string,
104
+ serverNames: readonly string[],
105
+ ): string {
106
+ if (!toolKey) return toolKey
107
+ let rest = toolKey
108
+ for (const server of serverNames) {
109
+ const prefix = `${sanitizeKey(server)}_`
110
+ if (rest.startsWith(prefix)) {
111
+ rest = rest.slice(prefix.length)
112
+ break
113
+ }
114
+ }
115
+ // One unknown gateway layer, and only one: `<alias>-<tool>`. The alias may
116
+ // not itself contain the separator, which is what keeps this bounded.
117
+ const layer = rest.indexOf(GATEWAY_SEPARATOR)
118
+ if (layer > 0) {
119
+ const remainder = rest.slice(layer + GATEWAY_SEPARATOR.length)
120
+ if (remainder) return remainder
121
+ }
122
+ return rest
123
+ }
124
+
125
+ export function toCanonicalTool(
126
+ toolKey: string,
127
+ serverNames: readonly string[],
128
+ gatewayAlias: string,
129
+ ): string {
130
+ if (!toolKey) return toolKey
131
+ if (!gatewayAlias) {
132
+ // No silent `|| "elliot"`. A missing alias means the caller is
133
+ // misconfigured, and guessing one would restore the duplicated literal
134
+ // this parameter exists to remove — while hiding the misconfiguration
135
+ // behind behaviour that happens to be right in one deployment.
136
+ throw new Error("toCanonicalTool requires the configured gateway alias")
137
+ }
138
+ const gatewayPrefix = `${gatewayAlias}${GATEWAY_SEPARATOR}`
139
+ let rest = toolKey
140
+
141
+ // One OpenCode `<server>_` prefix, and only for a server we are configured
142
+ // for. An unconfigured server's tools are not ours to recognise.
143
+ for (const server of serverNames) {
144
+ const prefix = `${sanitizeKey(server)}_`
145
+ if (rest.startsWith(prefix)) {
146
+ rest = rest.slice(prefix.length)
147
+ break
148
+ }
149
+ }
150
+
151
+ // Then one LiteLLM `<alias>-` prefix.
152
+ if (rest.startsWith(gatewayPrefix)) {
153
+ rest = rest.slice(gatewayPrefix.length)
154
+ }
155
+
156
+ return rest
157
+ }
@@ -19,7 +19,7 @@ import {
19
19
  killsAgentRuntime,
20
20
  looksLikeCredentialExfiltration,
21
21
  } from "./classifier.js"
22
- import { KNOWLEDGE_TOOL, knowledgeToolKey, sanitizeKey } from "./mcp-keys.js"
22
+ import { KNOWLEDGE_TOOL, sanitizeKey, toCanonicalTool } from "./mcp-keys.js"
23
23
 
24
24
  const OptionsSchema = z
25
25
  .object({
@@ -30,6 +30,27 @@ const OptionsSchema = z
30
30
  * what let the hooks recognise Elliot tools.
31
31
  */
32
32
  serverNames: z.array(z.string().min(1)).min(1).default(["elliot"]),
33
+ /**
34
+ * The LiteLLM MCP gateway's alias for Elliot — the `mcp_servers` key in
35
+ * `deploy/litellm/config.yaml`, which LiteLLM also uses as the prefix on
36
+ * every tool it proxies (`<alias>-<tool>`).
37
+ *
38
+ * REQUIRED, and deliberately without a default. This value used to be a
39
+ * constant in `mcp-keys.ts`, a fourth copy of a literal that also lives in
40
+ * the backend settings, the backend namespace module and the LiteLLM YAML.
41
+ * A default here would be that copy again: a plugin configured without the
42
+ * option would keep working against a guess, and would break silently on
43
+ * the day someone renamed the YAML key — recognising no gateway-prefixed
44
+ * tool, injecting no repo scope, and reporting nothing.
45
+ *
46
+ * The backend serves it with the plugin spec (`routers/well_known.py`),
47
+ * from `litellm_mcp_server_alias`.
48
+ *
49
+ * Distinct from `serverNames`, which is OpenCode's own server key and
50
+ * joins with an UNDERSCORE. Both read "elliot" in the default deployment
51
+ * and they are not the same value.
52
+ */
53
+ gatewayAlias: z.string().min(1),
33
54
  })
34
55
  .strict()
35
56
 
@@ -53,11 +74,22 @@ export { KNOWLEDGE_TOOL, sanitizeKey }
53
74
 
54
75
  /**
55
76
  * Is this agent-visible tool key Elliot's knowledge tool on one of our
56
- * configured servers? Exact composed-key match only — a suffix match would let
57
- * an unrelated MCP server named e.g. "not-elliot" receive our scope hints.
77
+ * configured servers?
78
+ *
79
+ * NORMALISED FIRST, then matched exactly. It used to compare against one
80
+ * composed spelling, which stopped matching the moment runtime traffic moved
81
+ * behind the LiteLLM MCP gateway and the key gained a second namespace
82
+ * (`elliot_elliot-knowledge_search`). A knowledge call that is not recognised
83
+ * here is one that silently loses its repository scope — the agent still
84
+ * answers, just tenant-wide instead of repo-focused, and nothing in the reply
85
+ * says so.
86
+ *
87
+ * The anti-substring property is unchanged: `toCanonicalTool` strips only a
88
+ * prefix belonging to a CONFIGURED server, so `not-elliot_knowledge_search`
89
+ * still does not match.
58
90
  */
59
91
  export function isElliotKnowledgeTool(toolKey: string, options: ElliotPluginOptions): boolean {
60
- return options.serverNames.some((server) => toolKey === knowledgeToolKey(server))
92
+ return toCanonicalTool(toolKey, options.serverNames, options.gatewayAlias) === KNOWLEDGE_TOOL
61
93
  }
62
94
 
63
95
  // -- Shell policy verdicts ---------------------------------------------------
@@ -1,96 +1,148 @@
1
1
  /**
2
- * The Elliot proxy provider: a `config` hook that registers a custom
3
- * `elliot` OpenCode provider pointed at the Elliot LightLLM proxy, and an
4
- * `auth` hook whose `loader` returns a self-refreshing `fetch` that attaches
5
- * a live bearer token per request.
2
+ * The Elliot provider: a `config` hook that registers a custom `elliot`
3
+ * OpenCode provider pointed at LiteLLM directly.
6
4
  *
7
- * Mechanism pinned in `.superpowers/sdd/2026-09-03-eai-proxy-auth/task-1-findings.md`:
8
- * - `config` hook mutates its input in place (no separate output param).
9
- * - `auth.loader`'s return value is spread wholesale into the provider's
10
- * resolved `options`, so `{ apiKey, fetch }` land there directly.
11
- * - `apiKey` is baked into the AI-SDK client at construction time and
12
- * never changes; the actually-rotating credential is injected per
13
- * request by `options.fetch`'s `Authorization` header (never the static
14
- * `apiKey`) — same pattern as OpenCode's bundled snowflake-cortex plugin.
15
- * - The loader only runs once a stored auth entry exists for its
16
- * `provider` id (`provider.ts:1584`); the plugin seeds that entry itself
17
- * at init (see `index.ts`) so no interactive `opencode auth login` step
18
- * is ever required.
5
+ * Since `/llm-proxy` was removed, this no longer needs a self-refreshing
6
+ * per-request `auth` hook (Task-1's pinned mechanism, `.superpowers/sdd/
7
+ * 2026-09-03-eai-proxy-auth/task-1-findings.md` — kept only as history: the
8
+ * per-instance JWT that mechanism refreshed doesn't authenticate this path
9
+ * anymore). LiteLLM's `litellm_master_key` is ONE static secret for the whole
10
+ * gateway — it never expires, so it is baked into `options.apiKey` directly,
11
+ * the same way OpenCode's own `{env:VAR}` config substitution would, just
12
+ * read from `~/.elliot/config.json` instead of the process environment.
19
13
  *
20
14
  * Everything here is a no-op when the user isn't logged in
21
15
  * (`readElliotConfig()` returns null) — OpenCode then runs exactly as it
22
16
  * would without this plugin.
23
17
  */
24
- import { freshToken, readElliotConfig } from "./elliot-auth.js"
18
+ import { readElliotConfig, type ElliotCredential } from "./elliot-auth.js"
25
19
 
26
- export const ELLIOT_LLM_PROXY_URL = "https://backend.elliotsystemsonline.com/llm-proxy"
27
20
  export const ELLIOT_PROVIDER_ID = "elliot"
21
+ export const ELLIOT_GENERAL_MODEL_ID = "elliot-general"
22
+ export const ELLIOT_CODER_MODEL_ID = "elliot-coder"
23
+
24
+ const ALL_MODELS = {
25
+ [ELLIOT_GENERAL_MODEL_ID]: { name: "Elliot General" },
26
+ [ELLIOT_CODER_MODEL_ID]: { name: "Elliot Coder" },
27
+ }
28
28
 
29
29
  /**
30
- * Registers the `elliot` provider (baseURL = the LightLLM proxy) and
31
- * defaults `cfg.model` to it, mutating `cfg` in place. No-op when the user
32
- * isn't logged in. Never overwrites a model the user already configured, or
33
- * any other existing key under `cfg.provider`/`cfg.plugin`/`cfg.mcp`/etc.
30
+ * The `models` block for one registered provider, filtered to
31
+ * `allowedModels` (Phase 4 governance, docs/LITELLM_GATEWAY.md §14) —
32
+ * LiteLLM gateway audit finding #5, mirrors the identical fix already made
33
+ * to `app/services/opencode/teamconfig.py::build_opencode_config` and
34
+ * `elliot-launcher/lib/setup.js::elliotProvider`. LiteLLM was always the
35
+ * real enforcement point regardless of what this offers; this only stops
36
+ * the menu from offering a model that will be rejected. Falsy
37
+ * (undefined/null/empty) — every team until an admin restricts it — offers
38
+ * both, unchanged from before this parameter existed.
39
+ *
40
+ * A team restricted to ONLY elliot-classifier (never offered here —
41
+ * internal-only, deploy/litellm/config.yaml §5) would otherwise get an
42
+ * empty, unusable menu; degrades to offering both for that listing only,
43
+ * same as the two sibling generators — LiteLLM still rejects any actual
44
+ * request the team isn't allowed to make either way.
34
45
  */
35
- export function configHook(cfg: any): void {
36
- if (!readElliotConfig()) return
46
+ function offeredModels(allowedModels?: string[] | null): typeof ALL_MODELS {
47
+ if (!allowedModels || allowedModels.length === 0) return ALL_MODELS
37
48
 
38
- cfg.provider ??= {}
39
- cfg.provider[ELLIOT_PROVIDER_ID] = {
40
- npm: "@ai-sdk/anthropic",
41
- name: "Elliot (governed)",
42
- options: { baseURL: ELLIOT_LLM_PROXY_URL },
43
- models: { "elliot-governed": { name: "Elliot (governed)" } },
44
- }
49
+ const allowed = new Set(allowedModels)
50
+ const offered = Object.keys(ALL_MODELS).filter((id) => allowed.has(id)) as Array<
51
+ keyof typeof ALL_MODELS
52
+ >
53
+ if (offered.length === 0) return ALL_MODELS
45
54
 
46
- cfg.model ??= "elliot/elliot-governed"
55
+ const filtered = {} as typeof ALL_MODELS
56
+ for (const id of offered) filtered[id] = ALL_MODELS[id]
57
+ return filtered
47
58
  }
48
59
 
49
- /**
50
- * Merge `Authorization: Bearer <token>` into whatever headers `init` already
51
- * carries, without dropping them. Works whether `init.headers` is a plain
52
- * object, a `Headers` instance, or an array of pairs.
53
- */
54
- function withAuthHeader(init: RequestInit | undefined, token: string): RequestInit {
55
- const headers = new Headers(init?.headers)
56
- headers.set("Authorization", `Bearer ${token}`)
57
- return { ...init, headers }
60
+ function registerProvider(
61
+ cfg: any,
62
+ base: string,
63
+ providerId: string,
64
+ label: string,
65
+ apiKey: string,
66
+ allowedModels?: string[] | null,
67
+ ): void {
68
+ cfg.provider ??= {}
69
+ // Never clobber a provider id the user (or an earlier plugin) already
70
+ // configured — same "additive only" contract as the single-provider path
71
+ // this replaces.
72
+ cfg.provider[providerId] ??= {
73
+ npm: "@ai-sdk/openai-compatible",
74
+ name: label,
75
+ options: { baseURL: `${base}/v1`, apiKey },
76
+ models: offeredModels(allowedModels),
77
+ }
58
78
  }
59
79
 
60
80
  /**
61
- * The per-request fetch wrapper: reads `~/.elliot/config.json` fresh on
62
- * every call (so a refresh picked up by another process is honored
63
- * immediately), refreshes via `freshToken` when needed, and attaches the
64
- * bearer token. Never throws — a missing/expired/unrefreshable token just
65
- * means the request goes out unauthenticated and the proxy 401s.
81
+ * Registers one OpenCode provider per LiteLLM credential this person has
82
+ * (Phase 4, docs/LITELLM_GATEWAY.md §14): their own personal key under
83
+ * `elliot`, plus one `elliot-team-<slug>` provider per team they belong to —
84
+ * so `opencode.json`'s own global+project-local merge, not this plugin, is
85
+ * what decides which key a given folder actually uses (a project-local
86
+ * config pins `model` to a team's provider; the global default falls back to
87
+ * the personal one). Mutating `cfg` in place.
88
+ *
89
+ * Falls back to registering a single `elliot` provider from the legacy
90
+ * `litellm_master_key` field when `elliot.credentials` is absent — an
91
+ * `eai setup` written before this phase, or a backend that predates it,
92
+ * keeps working unchanged rather than losing LLM access outright.
93
+ *
94
+ * No-op when the user isn't logged in. Never overwrites a model the user
95
+ * already configured, or any other existing key under
96
+ * `cfg.provider`/`cfg.plugin`/`cfg.mcp`/etc.
66
97
  */
67
- async function elliotFetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {
68
- try {
69
- const cfg = readElliotConfig()
70
- const token = cfg ? await freshToken(cfg) : null
71
- if (token) {
72
- return globalThis.fetch(input, withAuthHeader(init, token))
98
+ export function configHook(cfg: any): void {
99
+ const elliot = readElliotConfig()
100
+ if (!elliot?.litellm_base_url) return
101
+
102
+ const base = elliot.litellm_base_url.replace(/\/+$/, "")
103
+
104
+ // The DEFAULT provider's own offered models decide the default `model`
105
+ // below — never just `elliot-general` unconditionally, since the
106
+ // fallback (single-provider) path can itself be restricted. In the
107
+ // credentials path this is always the personal credential (provider_id
108
+ // "elliot"), which has no team and so is never restricted — `ALL_MODELS`
109
+ // unless a future personal restriction concept changes that.
110
+ let defaultProviderModels: typeof ALL_MODELS = ALL_MODELS
111
+
112
+ if (elliot.credentials?.length) {
113
+ for (const credential of elliot.credentials as ElliotCredential[]) {
114
+ if (!credential.provider_id || !credential.api_key) continue
115
+ registerProvider(
116
+ cfg,
117
+ base,
118
+ credential.provider_id,
119
+ credential.label,
120
+ credential.api_key,
121
+ credential.allowed_models,
122
+ )
123
+ if (credential.provider_id === ELLIOT_PROVIDER_ID) {
124
+ defaultProviderModels = offeredModels(credential.allowed_models)
125
+ }
73
126
  }
74
- return globalThis.fetch(input, init)
75
- } catch {
76
- return globalThis.fetch(input, init)
127
+ } else if (elliot.litellm_master_key) {
128
+ registerProvider(
129
+ cfg,
130
+ base,
131
+ ELLIOT_PROVIDER_ID,
132
+ "Elliot (LiteLLM)",
133
+ elliot.litellm_master_key,
134
+ elliot.allowed_models,
135
+ )
136
+ defaultProviderModels = offeredModels(elliot.allowed_models)
137
+ } else {
138
+ return
77
139
  }
78
- }
79
140
 
80
- /**
81
- * Builds the `auth` hook object per the Task-1 pinned `AuthHook` shape.
82
- * `methods` is empty — this provider is never set up via interactive
83
- * `opencode auth login`; the plugin seeds the stored auth entry itself
84
- * (see `index.ts`) so the loader always fires once the user is logged into
85
- * Elliot.
86
- */
87
- export function makeAuthHook() {
88
- return {
89
- provider: ELLIOT_PROVIDER_ID,
90
- methods: [] as unknown[],
91
- loader: async (_auth: () => Promise<unknown>, _provider: unknown) => ({
92
- apiKey: "elliot-managed",
93
- fetch: elliotFetch,
94
- }),
95
- }
141
+ // Follows the restriction, not always General — a team restricted to
142
+ // Coder-only must not default to a model its own menu just excluded.
143
+ const defaultModelId =
144
+ ELLIOT_GENERAL_MODEL_ID in defaultProviderModels
145
+ ? ELLIOT_GENERAL_MODEL_ID
146
+ : Object.keys(defaultProviderModels)[0]
147
+ cfg.model ??= `${ELLIOT_PROVIDER_ID}/${defaultModelId}`
96
148
  }
@@ -2,7 +2,8 @@
2
2
  * Repository scope from the session worktree — the trusted-context half of the
3
3
  * "the model never names a repository" invariant.
4
4
  *
5
- * Ported (trimmed, not imported) from `elliot-cli/src/project.ts`: the same
5
+ * Ported (trimmed, not imported) from the retired elliot-ai CLI's
6
+ * `src/project.ts`: the same
6
7
  * `execFile` discipline — a fixed, hardcoded argument array, never a string a
7
8
  * shell would parse, and never anything the LLM supplies. The worktree path
8
9
  * comes from OpenCode's own `PluginInput`/`ToolContext`, which is session