@staticeng/opencode-litellm 0.2.7 → 0.3.1

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 CHANGED
@@ -4,23 +4,20 @@ Runtime LiteLLM model autodiscovery for OpenCode. The plugin fetches LiteLLM dep
4
4
 
5
5
  ## Install
6
6
 
7
- Install the package by npm name, then add it to OpenCode `plugin` config:
8
-
9
- ```sh
10
- npm install @staticeng/opencode-litellm
11
- ```
7
+ For OpenCode V2, add the published package to `~/.config/opencode/opencode.json`.
8
+ OpenCode downloads npm plugins automatically; no manual `npm install` is needed.
12
9
 
13
10
  ```json
14
11
  {
15
12
  "$schema": "https://opencode.ai/config.json",
16
- "plugin": [
17
- [
18
- "@staticeng/opencode-litellm",
19
- {
13
+ "plugins": [
14
+ {
15
+ "package": "@staticeng/opencode-litellm@0.3.0",
16
+ "options": {
20
17
  "baseURL": "https://litellm.example.com/v1",
21
18
  "apiKeyEnv": "LITELLM_API_KEY"
22
19
  }
23
- ]
20
+ }
24
21
  ]
25
22
  }
26
23
  ```
@@ -35,6 +32,16 @@ Do not put real API keys in `opencode.json`. If you pass `apiKey` directly, the
35
32
 
36
33
  Shared or synchronized OpenCode configuration should use the published package name as shown above. Do not use a local path or `file://` plugin reference for shared configuration.
37
34
 
35
+ Version 0.3.0 exposes native V2 `id`/`setup` and a V1 `server` entrypoint.
36
+ V1 object entrypoints require OpenCode 1.18.29 or newer; use the V1 `plugin`
37
+ package-and-options tuple on those releases. OpenCode V2 uses `plugins` and
38
+ the object shown above. Versions through 0.2.7 only expose V1 hooks.
39
+
40
+ After changing the package version, reload configuration or start a new OpenCode
41
+ process, then use `/models` to select a model under `LiteLLM`. For synchronized
42
+ setups, edit the authoritative configuration once and wait for Syncthing to
43
+ deliver it to each peer. Each host installs its own npm package cache.
44
+
38
45
  ## Options
39
46
 
40
47
  | Option | Default | Purpose |
@@ -42,7 +49,7 @@ Shared or synchronized OpenCode configuration should use the published package n
42
49
  | `baseURL` | `LITELLM_BASE_URL` | LiteLLM proxy base URL. `/v1` URLs are supported. |
43
50
  | `apiKey` | env value | Bearer token for LiteLLM. |
44
51
  | `apiKeyEnv` | `LITELLM_API_KEY` | Environment variable used when `apiKey` is not set. |
45
- | `providerKey` | `LiteLLM` | Key written to legacy `cfg.provider` and V2 `cfg.providers`. |
52
+ | `providerKey` | `LiteLLM` | Provider ID registered in OpenCode. |
46
53
  | `providerName` | `LiteLLM` | Human provider name. |
47
54
  | `overrides` | `{}` | Per-model deep-merge overrides. |
48
55
  | `providerOverrides` | `{}` | Provider-level deep-merge overrides. |
@@ -65,19 +72,26 @@ When LiteLLM publishes `model_info.supported_reasoning_efforts`, the plugin maps
65
72
 
66
73
  ```json
67
74
  {
68
- "plugin": [
69
- [
70
- "@staticeng/opencode-litellm",
71
- {
75
+ "plugins": [
76
+ {
77
+ "package": "@staticeng/opencode-litellm@0.3.0",
78
+ "options": {
72
79
  "baseURL": "https://litellm.example.com/v1",
73
80
  "apiKeyEnv": "LITELLM_API_KEY",
74
81
  "overrides": {
75
82
  "chatgpt/gpt-5.5": {
76
- "limit": { "context": 262144, "output": 128000 },
83
+ "limit": {
84
+ "context": 262144,
85
+ "output": 128000
86
+ },
77
87
  "variants": {
78
- "high": { "reasoningEffort": "high" }
88
+ "high": {
89
+ "reasoningEffort": "high"
90
+ }
79
91
  },
80
- "interleaved": { "field": "reasoning_details" }
92
+ "interleaved": {
93
+ "field": "reasoning_details"
94
+ }
81
95
  }
82
96
  },
83
97
  "providerOverrides": {
@@ -86,7 +100,7 @@ When LiteLLM publishes `model_info.supported_reasoning_efforts`, the plugin maps
86
100
  }
87
101
  }
88
102
  }
89
- ]
103
+ }
90
104
  ]
91
105
  }
92
106
  ```
@@ -139,7 +153,7 @@ Normal GPT-5.3 Codex and GPT-5.3 Codex Spark aliases are retired and filtered ev
139
153
 
140
154
  - Fetches `${baseURL}/model/info`, then root `/model_group/info`, with separate authenticated and timed requests.
141
155
  - Preserves `/v1` for model-info discovery but resolves model-group discovery from the LiteLLM origin.
142
- - Adds or replaces both legacy `cfg.provider[providerKey]` and V2 `cfg.providers[providerKey]` during the config hook.
156
+ - Registers the provider and models with the native V2 provider transform API; V1 uses the config hook.
143
157
  - Keeps namespaced deployment IDs and augments them with exact bare model-group IDs; exact collisions are enriched deterministically.
144
158
  - Skips malformed group rows without a nonblank `model_group` and exposes reasoning capability on legacy and V2 model metadata.
145
159
  - Applies exact built-in model contracts as defaults, then applies explicit model and provider overrides last.
@@ -164,7 +178,13 @@ npm pack --dry-run
164
178
 
165
179
  Confirm package contents before publishing and keep secrets out of fixtures, docs, logs, and config examples.
166
180
 
167
- Publishing uses npm trusted publishing from the `npm-production` GitHub environment. Provenance
181
+ The GitHub release workflow supports npm trusted publishing from the `npm-production` environment. Provenance
168
182
  requires this GitHub repository to remain public. Before a release, configure npm's trusted
169
183
  publisher for package `@staticeng/opencode-litellm` with this repository, workflow
170
184
  `publish.yml`, and environment `npm-production`; no npm token is used by the workflow.
185
+
186
+ ### Local RTX 5090 Strata aliases
187
+
188
+ The exact discovered aliases `5090-local-model` and `5090-local-model-codex` use the Qwen3.8 Flash Next Strata contract: text/image input, text output, reasoning, tools, and 262144 tokens of shared context. The output ceiling is 262144 within that shared window; it is not an additional output budget. Pricing continues to come from LiteLLM metadata.
189
+
190
+ Both aliases preserve their routable IDs and use Chat Completions in OpenCode. Their public variants are `off`, `low`, `medium`, and `high` (default `high`): `off` sends `reasoning_effort: "none"`; the other variants send their named effort. Strata translates public `high` to its native engine effort. This differs from the cloud Qwen contract and does not alter it. The `-codex` alias remains available to Codex via LiteLLM's separate Responses adapter. No alias is fabricated when discovery omits it; explicit model/provider overrides still win.
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Config, PluginInput } from "@opencode-ai/plugin";
2
2
  import type { LiteLLMPluginOptions } from "./types.js";
3
- declare const LiteLLMPlugin: (ctx: PluginInput, options?: LiteLLMPluginOptions) => Promise<{
3
+ export declare const LiteLLMPlugin: (ctx: PluginInput, options?: LiteLLMPluginOptions) => Promise<{
4
4
  config: (cfg: Config) => Promise<void>;
5
5
  "chat.params": (input: {
6
6
  sessionID: string;
@@ -16,4 +16,24 @@ declare const LiteLLMPlugin: (ctx: PluginInput, options?: LiteLLMPluginOptions)
16
16
  options: Record<string, any>;
17
17
  }) => Promise<void>;
18
18
  }>;
19
- export default LiteLLMPlugin;
19
+ declare const _default: {
20
+ id: string;
21
+ server: (ctx: PluginInput, options?: LiteLLMPluginOptions) => Promise<{
22
+ config: (cfg: Config) => Promise<void>;
23
+ "chat.params": (input: {
24
+ sessionID: string;
25
+ agent: string;
26
+ model: import("@opencode-ai/sdk").Model;
27
+ provider: import("@opencode-ai/plugin").ProviderContext;
28
+ message: import("@opencode-ai/sdk").UserMessage;
29
+ }, output: {
30
+ temperature: number;
31
+ topP: number;
32
+ topK: number;
33
+ maxOutputTokens: number | undefined;
34
+ options: Record<string, any>;
35
+ }) => Promise<void>;
36
+ }>;
37
+ setup: (ctx: import("@opencode/plugin/promise/plugin").Context) => Promise<void>;
38
+ };
39
+ export default _default;
package/dist/index.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { fetchModelGroupInfo, fetchModelInfo } from "./litellm.js";
2
2
  import { buildProvider, buildProviderV2, isContractReasoningModel, isQwen38ReasoningModel, mergeModelInfo, } from "./mapping.js";
3
3
  import { resolveOptions } from "./options.js";
4
+ import V2Plugin from "./v2.js";
4
5
  async function log(ctx, level, message, extra = {}) {
5
6
  await ctx.client?.app?.log?.({ body: { service: "opencode-litellm", level, message, extra } });
6
7
  }
7
- const LiteLLMPlugin = async (ctx, options) => {
8
+ export const LiteLLMPlugin = async (ctx, options) => {
8
9
  const resolved = resolveOptions(options);
9
10
  return {
10
11
  config: async (cfg) => {
@@ -68,4 +69,8 @@ const LiteLLMPlugin = async (ctx, options) => {
68
69
  }),
69
70
  };
70
71
  };
71
- export default LiteLLMPlugin;
72
+ export default {
73
+ id: V2Plugin.id,
74
+ server: LiteLLMPlugin,
75
+ setup: V2Plugin.setup,
76
+ };
package/dist/mapping.js CHANGED
@@ -464,9 +464,24 @@ export function mapModelsV2(models, options = {}) {
464
464
  const mapped = v2Model(model, options.baseURL);
465
465
  const variants = contractV2Variants(name, model, options.overrides?.[name]);
466
466
  const result = variants ? { ...mapped, variants } : mapped;
467
+ const contract = modelContract(name);
468
+ const defaultBody = isRecord(model.options) ? v2VariantBody(model.options) : {};
469
+ delete defaultBody.variantDefault;
470
+ const overriddenOptions = options.overrides?.[name]?.options;
471
+ const overridesEffort = isRecord(overriddenOptions) && ("reasoningEffort" in overriddenOptions ||
472
+ "reasoning_effort" in overriddenOptions ||
473
+ "chat_template_kwargs" in overriddenOptions);
474
+ // V2 applies the selected variant after request.body, so Qwen off must not inherit its default effort.
475
+ if (contract && mapped.api?.package !== "@ai-sdk/openai" && !overridesEffort) {
476
+ delete defaultBody.reasoning_effort;
477
+ delete defaultBody.reasoningEffort;
478
+ }
479
+ result.request = {
480
+ body: defaultBody,
481
+ ...(contract && !overridesEffort ? { variant: contract.defaultMode } : {}),
482
+ };
467
483
  if (mapped.api?.package === "@ai-sdk/openai") {
468
- const body = responsesBody(isRecord(model.options) ? model.options : {});
469
- result.request = { body };
484
+ result.request.body = responsesBody(result.request.body ?? {});
470
485
  result.variants = result.variants?.map((variant) => ({ ...variant, body: responsesBody(variant.body ?? {}) }));
471
486
  }
472
487
  return [name, result];
@@ -54,6 +54,15 @@ const QWEN_FLASH_NEXT_METADATA = Object.freeze({
54
54
  tool_call: true,
55
55
  limit: Object.freeze({ context: 262144, output: 0 }),
56
56
  });
57
+ // Strata exposes high publicly and translates it to the engine's native effort.
58
+ const STRATA_QWEN_MODES = Object.freeze([
59
+ Object.freeze({ id: "off", legacy: freezeJson({ reasoningEffort: "none" }), v2Body: freezeJson({ reasoning_effort: "none" }) }),
60
+ ...effortModes(["low", "medium", "high"]),
61
+ ]);
62
+ const STRATA_QWEN_METADATA = Object.freeze({
63
+ ...QWEN_FLASH_NEXT_METADATA,
64
+ limit: Object.freeze({ context: 262144, output: 262144 }),
65
+ });
57
66
  const chatGptAliases = (model) => Object.freeze([
58
67
  model,
59
68
  `chatgpt/${model}`,
@@ -73,6 +82,7 @@ export const MODEL_CONTRACTS = Object.freeze([
73
82
  contract("DeepSeek V4 Flash", ["deepseek-v4-flash-fp8-mtp", "deepseek-v4-flash-fp8-mtp-norefusal"], DEEPSEEK_MODES, "max"),
74
83
  contract("Qwen3.8", ["qwen3.8-27b-refusal-dial"], QWEN_MODES, "xhigh"),
75
84
  contract("Qwen3.8", ["qwen3.8-flash-next"], QWEN_MODES, "xhigh", QWEN_FLASH_NEXT_METADATA, true),
85
+ contract("Qwen3.8 Strata", ["5090-local-model", "5090-local-model-codex"], STRATA_QWEN_MODES, "high", STRATA_QWEN_METADATA, true),
76
86
  ]);
77
87
  export const RETIRED_MODEL_ALIASES = Object.freeze([
78
88
  ...chatGptAliases("gpt-5.3-codex"),
package/dist/v2.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ import type { Plugin as CurrentPlugin } from "@opencode/plugin";
2
+ type CurrentContext = Parameters<CurrentPlugin.Plugin["setup"]>[0];
3
+ declare const LiteLLMPluginV2: {
4
+ id: string;
5
+ setup: (ctx: CurrentContext) => Promise<void>;
6
+ };
7
+ export default LiteLLMPluginV2;
package/dist/v2.js ADDED
@@ -0,0 +1,287 @@
1
+ import { fetchModelGroupInfo, fetchModelInfo } from "./litellm.js";
2
+ import { buildProviderV2, mergeModelInfo } from "./mapping.js";
3
+ import { deepMerge } from "./merge.js";
4
+ import { resolveOptions } from "./options.js";
5
+ const CHAT_PACKAGE = "@opencode/ai/providers/openai-compatible";
6
+ // The 2.0.16 binary bundles this driver, but not openai-compatible/responses.
7
+ const RESPONSES_PACKAGE = "@opencode/ai/providers/openai/responses";
8
+ function packageName(name) {
9
+ if (name === "@ai-sdk/openai" || name === "@opencode/ai/providers/openai-compatible/responses")
10
+ return RESPONSES_PACKAGE;
11
+ if (name === "@ai-sdk/openai-compatible")
12
+ return CHAT_PACKAGE;
13
+ return name;
14
+ }
15
+ function isRecord(value) {
16
+ return typeof value === "object" && value !== null && !Array.isArray(value);
17
+ }
18
+ function costTiers(cost) {
19
+ if (cost === undefined)
20
+ return [];
21
+ return (Array.isArray(cost) ? cost : [cost]).map((tier) => ({
22
+ ...(tier.tier ? { tier: { ...tier.tier } } : {}),
23
+ input: tier.input,
24
+ output: tier.output,
25
+ cache: { read: tier.cache?.read ?? 0, write: tier.cache?.write ?? 0 },
26
+ }));
27
+ }
28
+ function currentProviderPackage(config) {
29
+ return packageName(typeof config.npm === "string" ? config.npm : config.api.package);
30
+ }
31
+ function currentModelPackage(config, providerPackage) {
32
+ return packageName(config.api?.package === "@ai-sdk/openai-compatible" ? providerPackage : config.api?.package ?? providerPackage);
33
+ }
34
+ function currentModel(providerID, id, config, providerPackage) {
35
+ const settings = {
36
+ ...config.api?.settings,
37
+ ...(config.api?.url ? { baseURL: config.api.url } : {}),
38
+ };
39
+ const body = { ...config.request?.body };
40
+ // Current V2 has no default-variant field; a session hook supplies that effort for default requests.
41
+ delete body.reasoning_effort;
42
+ delete body.reasoningEffort;
43
+ if (isRecord(body.reasoning)) {
44
+ const { effort: _effort, ...reasoning } = body.reasoning;
45
+ if (Object.keys(reasoning).length)
46
+ body.reasoning = reasoning;
47
+ else
48
+ delete body.reasoning;
49
+ }
50
+ return {
51
+ id,
52
+ modelID: config.api?.id ?? id,
53
+ providerID,
54
+ name: config.name ?? id,
55
+ ...(config.family ? { family: config.family } : {}),
56
+ package: currentModelPackage(config, providerPackage),
57
+ ...(Object.keys(settings).length ? { settings } : {}),
58
+ ...(config.request?.headers ? { headers: { ...config.request.headers } } : {}),
59
+ ...(Object.keys(body).length ? { body } : {}),
60
+ capabilities: {
61
+ tools: config.capabilities?.tools ?? false,
62
+ input: [...(config.capabilities?.input ?? ["text"])],
63
+ output: [...(config.capabilities?.output ?? ["text"])],
64
+ },
65
+ variants: (config.variants ?? []).map((variant) => ({
66
+ id: variant.id,
67
+ ...(variant.headers ? { headers: { ...variant.headers } } : {}),
68
+ ...(variant.body ? { body: { ...variant.body } } : {}),
69
+ })),
70
+ time: { released: 0 },
71
+ cost: costTiers(config.cost),
72
+ status: "active",
73
+ enabled: !config.disabled,
74
+ limit: {
75
+ context: config.limit?.context ?? 0,
76
+ ...(config.limit?.input === undefined ? {} : { input: config.limit.input }),
77
+ output: config.limit?.output ?? 0,
78
+ },
79
+ };
80
+ }
81
+ function defaultVariants(provider) {
82
+ return new Map(Object.entries(provider.models).flatMap(([id, model]) => {
83
+ const selected = model.variants?.find((variant) => variant.id === model.request?.variant);
84
+ return selected ? [[id, selected]] : [];
85
+ }));
86
+ }
87
+ function defaultEfforts(provider) {
88
+ const variants = defaultVariants(provider);
89
+ return new Map(Object.entries(provider.models).flatMap(([id, model]) => {
90
+ const selected = variants.get(id);
91
+ const body = selected?.body ?? model.request?.body;
92
+ const reasoning = body?.reasoning;
93
+ const effort = typeof reasoning === "object" && reasoning !== null && !Array.isArray(reasoning)
94
+ ? reasoning.effort
95
+ : body?.reasoning_effort ?? body?.reasoningEffort;
96
+ return typeof effort === "string" ? [[id, effort]] : [];
97
+ }));
98
+ }
99
+ async function registerCurrent(ctx, providerID, config) {
100
+ const providerOptions = isRecord(config.options) ? config.options : {};
101
+ const { body: optionBody, ...optionSettings } = providerOptions;
102
+ const providerPackage = currentProviderPackage(config);
103
+ const mergedBody = deepMerge(config.request.body, isRecord(optionBody) ? optionBody : undefined);
104
+ const { apiKey, ...body } = mergedBody;
105
+ const settings = {
106
+ ...(typeof config.request.body.apiKey === "string" ? { apiKey: config.request.body.apiKey } : {}),
107
+ ...config.api.settings,
108
+ ...(config.api.url ? { baseURL: config.api.url } : {}),
109
+ ...(isRecord(optionBody) && typeof optionBody.apiKey === "string" ? { apiKey: optionBody.apiKey } : {}),
110
+ ...optionSettings,
111
+ };
112
+ const models = Object.entries(config.models).map(([id, model]) => currentModel(providerID, id, model, providerPackage));
113
+ await ctx.provider.transform((editor) => {
114
+ editor.update(providerID, (provider) => {
115
+ provider.name = config.name ?? providerID;
116
+ provider.activation = config.disabled ? "disabled" : "enabled";
117
+ provider.package = providerPackage;
118
+ provider.settings = { ...settings };
119
+ provider.headers = { ...config.request.headers };
120
+ provider.body = { ...body };
121
+ if (typeof config.integrationID === "string")
122
+ provider.integrationID = config.integrationID;
123
+ });
124
+ editor.models.set(providerID, models);
125
+ });
126
+ const defaults = defaultEfforts(config);
127
+ if (defaults.size) {
128
+ for (const hook of ["context", "compaction", "generate", "title"]) {
129
+ await ctx.session.hook(hook, (event) => {
130
+ if (event.model.providerID !== providerID || (event.model.variant && event.model.variant !== "default"))
131
+ return;
132
+ const effort = defaults.get(event.model.id);
133
+ if (effort && event.options.reasoningEffort === undefined)
134
+ event.options.reasoningEffort = effort;
135
+ });
136
+ }
137
+ }
138
+ const variants = defaultVariants(config);
139
+ if (variants.size) {
140
+ await ctx.session.hook("http.request", async (event) => {
141
+ if (event.model.providerID !== providerID || (event.model.variant && event.model.variant !== "default"))
142
+ return;
143
+ const selected = variants.get(event.model.id);
144
+ if (!selected)
145
+ return;
146
+ const headers = new Headers(event.request.headers);
147
+ for (const [name, value] of Object.entries(selected.headers ?? {}))
148
+ headers.set(name, value);
149
+ if (!selected.body || !Object.keys(selected.body).length) {
150
+ event.request = new Request(event.request, { headers });
151
+ return;
152
+ }
153
+ let wireBody;
154
+ try {
155
+ wireBody = JSON.parse(await event.request.clone().text());
156
+ }
157
+ catch {
158
+ return;
159
+ }
160
+ if (!isRecord(wireBody))
161
+ return;
162
+ const merged = deepMerge(wireBody, selected.body);
163
+ // A request-level effort already serialized by the driver takes precedence.
164
+ for (const key of ["reasoning_effort", "reasoningEffort"]) {
165
+ if (key in wireBody)
166
+ merged[key] = wireBody[key];
167
+ }
168
+ if (isRecord(wireBody.reasoning) && "effort" in wireBody.reasoning && isRecord(merged.reasoning)) {
169
+ merged.reasoning.effort = wireBody.reasoning.effort;
170
+ }
171
+ headers.delete("content-length");
172
+ event.request = new Request(event.request, { headers, body: JSON.stringify(merged) });
173
+ }, { providerID });
174
+ }
175
+ }
176
+ function applyTransitionalModel(catalog, providerID, id, config) {
177
+ catalog.model.update(providerID, id, (model) => {
178
+ if (config.family !== undefined)
179
+ model.family = config.family;
180
+ if (config.name !== undefined)
181
+ model.name = config.name;
182
+ if (config.api !== undefined)
183
+ model.api = {
184
+ ...model.api, ...config.api, id: config.api.id ?? id, type: "aisdk", package: config.api.package,
185
+ };
186
+ if (config.capabilities !== undefined)
187
+ model.capabilities = {
188
+ tools: config.capabilities.tools,
189
+ input: [...config.capabilities.input],
190
+ output: [...config.capabilities.output],
191
+ };
192
+ if (config.request !== undefined) {
193
+ Object.assign(model.request.headers, config.request.headers);
194
+ Object.assign(model.request.body, config.request.body);
195
+ if (config.request.variant !== undefined)
196
+ model.request.variant = config.request.variant;
197
+ }
198
+ if (config.variants !== undefined)
199
+ model.variants = config.variants.map((variant) => ({
200
+ id: variant.id, headers: { ...variant.headers }, body: { ...variant.body },
201
+ }));
202
+ if (config.cost !== undefined)
203
+ model.cost = costTiers(config.cost);
204
+ if (config.disabled !== undefined)
205
+ model.enabled = !config.disabled;
206
+ if (config.limit !== undefined)
207
+ model.limit = { ...model.limit, ...config.limit };
208
+ });
209
+ }
210
+ function applyTransitional(catalog, providerID, config) {
211
+ catalog.provider.update(providerID, (provider) => {
212
+ if (config.name !== undefined)
213
+ provider.name = config.name;
214
+ provider.api = { ...config.api };
215
+ Object.assign(provider.request.headers, config.request.headers);
216
+ Object.assign(provider.request.body, config.request.body);
217
+ if (typeof config.disabled === "boolean")
218
+ provider.disabled = config.disabled;
219
+ if (typeof config.integrationID === "string")
220
+ provider.integrationID = config.integrationID;
221
+ });
222
+ for (const [id, model] of Object.entries(config.models))
223
+ applyTransitionalModel(catalog, providerID, id, model);
224
+ }
225
+ const LiteLLMPluginV2 = {
226
+ id: "opencode-litellm",
227
+ setup: async (ctx) => {
228
+ const options = resolveOptions(ctx.options);
229
+ let provider;
230
+ try {
231
+ const deployments = await fetchModelInfo(options);
232
+ let groups = [];
233
+ try {
234
+ groups = await fetchModelGroupInfo(options);
235
+ }
236
+ catch (error) {
237
+ if (options.strict)
238
+ throw error;
239
+ console.warn("opencode-litellm: failed to load LiteLLM model group metadata", error instanceof Error ? error.message : String(error));
240
+ }
241
+ const models = mergeModelInfo(deployments, groups);
242
+ if (ctx.provider?.transform) {
243
+ const overrides = { ...options.overrides };
244
+ for (const model of models) {
245
+ if (model.model_info?.mode !== "responses")
246
+ continue;
247
+ const id = model.model_name ?? model.litellm_params?.model ?? model.model_info?.id;
248
+ if (!id)
249
+ continue;
250
+ // Opaque Responses aliases need the same transport as GPT-named models.
251
+ overrides[id] = deepMerge({ provider: { npm: "@ai-sdk/openai" } }, overrides[id]);
252
+ }
253
+ provider = buildProviderV2(models, { ...options, overrides });
254
+ for (const model of models) {
255
+ if (model.model_info?.use_responses_lite !== true)
256
+ continue;
257
+ const id = model.model_name ?? model.litellm_params?.model ?? model.model_info?.id;
258
+ const mapped = id ? provider.models[id] : undefined;
259
+ if (!mapped || currentModelPackage(mapped, currentProviderPackage(provider)) !== RESPONSES_PACKAGE)
260
+ continue;
261
+ mapped.request ??= {};
262
+ mapped.request.body = deepMerge({ reasoning: { context: "all_turns" } }, mapped.request.body);
263
+ }
264
+ }
265
+ else {
266
+ provider = buildProviderV2(models, options);
267
+ }
268
+ }
269
+ catch (error) {
270
+ if (options.strict)
271
+ throw error;
272
+ console.warn("opencode-litellm: failed to load LiteLLM provider metadata", error instanceof Error ? error.message : String(error));
273
+ return;
274
+ }
275
+ if (ctx.provider?.transform) {
276
+ await registerCurrent(ctx, options.providerKey, provider);
277
+ return;
278
+ }
279
+ const transitional = ctx;
280
+ if (transitional.catalog?.transform) {
281
+ await transitional.catalog.transform((catalog) => applyTransitional(catalog, options.providerKey, provider));
282
+ return;
283
+ }
284
+ throw new Error("opencode-litellm requires an OpenCode V2 provider or catalog transform API");
285
+ },
286
+ };
287
+ export default LiteLLMPluginV2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staticeng/opencode-litellm",
3
- "version": "0.2.7",
3
+ "version": "0.3.1",
4
4
  "description": "OpenCode plugin that discovers LiteLLM models at runtime.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -14,6 +14,10 @@
14
14
  "./helpers": {
15
15
  "types": "./dist/helpers.d.ts",
16
16
  "import": "./dist/helpers.js"
17
+ },
18
+ "./v2": {
19
+ "types": "./dist/v2.d.ts",
20
+ "import": "./dist/v2.js"
17
21
  }
18
22
  },
19
23
  "files": [
@@ -27,13 +31,25 @@
27
31
  "prepack": "npm run build"
28
32
  },
29
33
  "peerDependencies": {
30
- "@opencode-ai/plugin": "*"
34
+ "@opencode-ai/plugin": ">=1.18.29",
35
+ "@opencode/plugin": ">=2.0.16"
36
+ },
37
+ "peerDependenciesMeta": {
38
+ "@opencode-ai/plugin": {
39
+ "optional": true
40
+ },
41
+ "@opencode/plugin": {
42
+ "optional": true
43
+ }
31
44
  },
32
45
  "devDependencies": {
33
46
  "@ai-sdk/openai": "3.0.84",
34
47
  "@ai-sdk/openai-compatible": "2.0.41",
35
- "@opencode-ai/plugin": "latest",
48
+ "@opencode-ai/plugin": "1.18.32",
49
+ "@opencode/ai": "2.0.16",
50
+ "@opencode/plugin": "2.0.16",
36
51
  "@types/node": "^24.10.1",
52
+ "effect": "4.0.0-rc.112",
37
53
  "typescript": "^5.9.3"
38
54
  },
39
55
  "engines": {