@elliotsystems/eai 0.1.3 → 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,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
@@ -14,7 +14,7 @@
14
14
  * proven from session state or not shown.
15
15
  */
16
16
 
17
- import { knowledgeToolKey } from "../mcp-keys.js"
17
+ import { KNOWLEDGE_TOOL, toCanonicalToolUnderAnyGateway } from "../mcp-keys.js"
18
18
 
19
19
  // ------------------------------------------------------------- host shapes --
20
20
 
@@ -106,9 +106,15 @@ export function elliotServerNames(items: readonly McpItem[]): string[] {
106
106
  * reasoning applies to a panel that would otherwise attribute a third party's
107
107
  * results to Elliot.
108
108
  */
109
- export function isKnowledgeTool(tool: string | undefined, serverNames: readonly string[]): boolean {
109
+ export function isKnowledgeTool(
110
+ tool: string | undefined,
111
+ serverNames: readonly string[],
112
+ ): boolean {
110
113
  if (typeof tool !== "string") return false
111
- return serverNames.some((name) => tool === knowledgeToolKey(name))
114
+ // Alias-agnostic BY NECESSITY, not by preference — see
115
+ // `toCanonicalToolUnderAnyGateway` for why the TUI cannot know the
116
+ // configured alias, and why that is safe for a display surface.
117
+ return toCanonicalToolUnderAnyGateway(tool, serverNames) === KNOWLEDGE_TOOL
112
118
  }
113
119
 
114
120
  /**