@staticeng/opencode-litellm 0.1.8 → 0.1.9

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
@@ -15,7 +15,7 @@ npm install @staticeng/opencode-litellm
15
15
  "$schema": "https://opencode.ai/config.json",
16
16
  "plugin": [
17
17
  [
18
- "@staticeng/opencode-litellm",
18
+ "@staticeng/opencode-litellm@0.1.9",
19
19
  {
20
20
  "baseURL": "https://litellm.example.com/v1",
21
21
  "apiKeyEnv": "LITELLM_API_KEY"
@@ -33,6 +33,8 @@ export LITELLM_API_KEY="..."
33
33
 
34
34
  Do not put real API keys in `opencode.json`. If you pass `apiKey` directly, the plugin uses it only in memory and never logs it, but environment variables are recommended.
35
35
 
36
+ Shared or synchronized OpenCode configuration must use the published, pinned npm package as shown above. Do not use a local path or `file://` plugin reference for shared configuration.
37
+
36
38
  ## Options
37
39
 
38
40
  | Option | Default | Purpose |
@@ -56,6 +58,10 @@ The LiteLLM key must have permission to call `GET /model/info` and root `GET /mo
56
58
 
57
59
  LiteLLM metadata does not expose every OpenCode model field. Use `overrides` to add variants, thinking controls, cache costs, interleaved reasoning settings, limits, modalities, timeouts, or provider-specific options.
58
60
 
61
+ When LiteLLM publishes `model_info.supported_reasoning_efforts`, the plugin maps
62
+ that list to OpenCode variants automatically. It does not guess effort levels
63
+ from model names; the LiteLLM catalog remains the source of truth.
64
+
59
65
  ```json
60
66
  {
61
67
  "plugin": [
@@ -68,7 +74,7 @@ LiteLLM metadata does not expose every OpenCode model field. Use `overrides` to
68
74
  "chatgpt/gpt-5.5": {
69
75
  "limit": { "context": 262144, "output": 128000 },
70
76
  "variants": {
71
- "high": { "options": { "reasoning_effort": "high" } }
77
+ "high": { "reasoningEffort": "high" }
72
78
  },
73
79
  "interleaved": { "field": "reasoning_details" }
74
80
  }
@@ -86,6 +92,10 @@ LiteLLM metadata does not expose every OpenCode model field. Use `overrides` to
86
92
 
87
93
  Overrides win over generated values through a recursive deep merge. Arrays and scalar values are replaced rather than concatenated.
88
94
 
95
+ The exact model groups `deepseek-v4-flash-fp8-mtp` and `deepseek-v4-flash-fp8-mtp-norefusal` are an exception: the plugin exposes only `off`, `low`, `high`, and `max`. Semantic `off` emits canonical `none`; the other values pass through unchanged. At the OpenCode config boundary, disabled `medium` and `xhigh` tombstones remove generic generated variants, while the model-scoped `variantDefault: false` capability hides the UI's default sentinel on compatible OpenCode builds. The plugin strips that UI-only marker in `chat.params` only when both the LiteLLM provider and an exact target alias match. Target model and provider overrides cannot change these controls. No manual model entry in `opencode.json` is needed.
96
+
97
+ The exact model group `qwen3.8-27b-refusal-dial` similarly exposes only `off`, `low`, `medium`, and `xhigh`. Its semantic `off` sends `chat_template_kwargs.enable_thinking=false` without a `reasoning_effort`; the native effort modes pass through unchanged. Disabled `none`, `minimal`, `high`, and `max` tombstones suppress OpenCode's unsupported generated variants. Official OpenCode 1.18.23 still owns an intrinsic default selection (`Predeterminado`) outside model variants; plugin configuration cannot remove it, and leaving it selected omits reasoning controls and preserves the model's deployed default.
98
+
89
99
  ## Runtime Behavior
90
100
 
91
101
  - Fetches `${baseURL}/model/info`, then root `/model_group/info`, with separate authenticated and timed requests.
@@ -93,8 +103,11 @@ Overrides win over generated values through a recursive deep merge. Arrays and s
93
103
  - Adds or replaces both legacy `cfg.provider[providerKey]` and V2 `cfg.providers[providerKey]` during the config hook.
94
104
  - Keeps namespaced deployment IDs and augments them with exact bare model-group IDs; exact collisions are enriched deterministically.
95
105
  - Skips malformed group rows without a nonblank `model_group` and exposes reasoning capability on legacy and V2 model metadata.
106
+ - Generates exact DeepSeek V4 and Qwen3.8 reasoning variants for the supported aliases and sanitizes conflicting target overrides after merging.
96
107
  - Warns safely and keeps deployment models when group discovery fails in non-strict mode; strict mode mutates neither config surface on failure.
97
108
  - Does not write `opencode.json`, cache model metadata, or persist generated provider data.
109
+ - Emits the required `limit.output` as `0` when LiteLLM reports a context/input limit but no output
110
+ limit. The zero sentinel retains known metadata without inventing a positive output allowance.
98
111
  - Includes `chat` and `responses` modes by default; non-chat modes such as `embedding` and `rerank` are excluded unless explicitly included.
99
112
  - No HuggingFace fallback is included in v1.
100
113
 
@@ -110,3 +123,8 @@ npm pack --dry-run
110
123
  ```
111
124
 
112
125
  Confirm package contents before publishing and keep secrets out of fixtures, docs, logs, and config examples.
126
+
127
+ Publishing uses npm trusted publishing from the `npm-production` GitHub environment. Provenance
128
+ requires this GitHub repository to remain public. Before a release, configure npm's trusted
129
+ publisher for package `@staticeng/opencode-litellm` with this repository, workflow
130
+ `publish.yml`, and environment `npm-production`; no npm token is used by the workflow.
package/dist/helpers.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { buildProvider, buildProviderV2, mapModels, mapModelsV2, mergeModelInfo, normalizeModelListResponse, modelListResponseShape, } from "./mapping.js";
1
+ export { addOpenCodeVariantTombstones, buildProvider, buildProviderV2, isDeepSeekV4ReasoningModel, mapModels, mapModelsV2, mergeModelInfo, normalizeModelListResponse, modelListResponseShape, } from "./mapping.js";
2
2
  export { fetchModelGroupInfo, fetchModelInfo, normalizeModelGroupResponse } from "./litellm.js";
3
3
  export { modelGroupInfoURL, modelInfoURL } from "./options.js";
4
4
  export { deepMerge } from "./merge.js";
package/dist/helpers.js CHANGED
@@ -1,4 +1,4 @@
1
- export { buildProvider, buildProviderV2, mapModels, mapModelsV2, mergeModelInfo, normalizeModelListResponse, modelListResponseShape, } from "./mapping.js";
1
+ export { addOpenCodeVariantTombstones, buildProvider, buildProviderV2, isDeepSeekV4ReasoningModel, mapModels, mapModelsV2, mergeModelInfo, normalizeModelListResponse, modelListResponseShape, } from "./mapping.js";
2
2
  export { fetchModelGroupInfo, fetchModelInfo, normalizeModelGroupResponse } from "./litellm.js";
3
3
  export { modelGroupInfoURL, modelInfoURL } from "./options.js";
4
4
  export { deepMerge } from "./merge.js";
package/dist/index.d.ts CHANGED
@@ -2,5 +2,18 @@ import type { Config, PluginInput } from "@opencode-ai/plugin";
2
2
  import type { LiteLLMPluginOptions } from "./types.js";
3
3
  declare const LiteLLMPlugin: (ctx: PluginInput, options?: LiteLLMPluginOptions) => Promise<{
4
4
  config: (cfg: Config) => Promise<void>;
5
+ "chat.params": (input: {
6
+ sessionID: string;
7
+ agent: string;
8
+ model: import("@opencode-ai/sdk").Model;
9
+ provider: import("@opencode-ai/plugin").ProviderContext;
10
+ message: import("@opencode-ai/sdk").UserMessage;
11
+ }, output: {
12
+ temperature: number;
13
+ topP: number;
14
+ topK: number;
15
+ maxOutputTokens: number | undefined;
16
+ options: Record<string, any>;
17
+ }) => Promise<void>;
5
18
  }>;
6
19
  export default LiteLLMPlugin;
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { fetchModelGroupInfo, fetchModelInfo } from "./litellm.js";
2
- import { buildProvider, buildProviderV2, mergeModelInfo } from "./mapping.js";
2
+ import { addOpenCodeVariantTombstones, buildProvider, buildProviderV2, isContractReasoningModel, mergeModelInfo, } from "./mapping.js";
3
3
  import { resolveOptions } from "./options.js";
4
4
  async function log(ctx, level, message, extra = {}) {
5
5
  await ctx.client?.app?.log?.({ body: { service: "opencode-litellm", level, message, extra } });
@@ -25,7 +25,7 @@ const LiteLLMPlugin = async (ctx, options) => {
25
25
  throw error;
26
26
  }
27
27
  const models = mergeModelInfo(deployments, groups);
28
- const provider = buildProvider(models, resolved);
28
+ const provider = addOpenCodeVariantTombstones(buildProvider(models, resolved));
29
29
  const config = cfg;
30
30
  const providerV2 = buildProviderV2(models, resolved);
31
31
  cfg.provider = {
@@ -53,6 +53,14 @@ const LiteLLMPlugin = async (ctx, options) => {
53
53
  throw error;
54
54
  }
55
55
  },
56
+ "chat.params": (async (input, output) => {
57
+ const provider = input.provider;
58
+ if ((provider.info?.id ?? provider.id) !== resolved.providerKey)
59
+ return;
60
+ if (!isContractReasoningModel(input.model.id))
61
+ return;
62
+ delete output.options.variantDefault;
63
+ }),
56
64
  };
57
65
  };
58
66
  export default LiteLLMPlugin;
package/dist/mapping.d.ts CHANGED
@@ -10,6 +10,9 @@ export type ModelListResponseShape = {
10
10
  export declare function modelListResponseShape(value: unknown): ModelListResponseShape;
11
11
  export declare function mergeModelInfo(deployments: LiteLLMModelInfo[], groups: LiteLLMModelInfo[]): LiteLLMModelInfo[];
12
12
  export declare function normalizeModelListResponse(response: unknown): LiteLLMModelInfo[];
13
+ export declare function isDeepSeekV4ReasoningModel(name: string | undefined): boolean;
14
+ export declare function isContractReasoningModel(name: string | undefined): boolean;
15
+ export declare function addOpenCodeVariantTombstones(provider: OpenCodeProvider): OpenCodeProvider;
13
16
  export declare function mapModels(response: unknown, options?: LiteLLMPluginOptions): {
14
17
  [k: string]: OpenCodeModel;
15
18
  };
package/dist/mapping.js CHANGED
@@ -4,6 +4,37 @@ const NON_CHAT_MODES = new Set(["embedding", "rerank"]);
4
4
  const TEXT_MODALITY = "text";
5
5
  const OPENCODE_MODALITIES = new Set(["text", "audio", "image", "video", "pdf"]);
6
6
  const TEXT_ONLY_MODELS = new Set(["minimax-m2.7", "minimax_m2.7"]);
7
+ const DEEPSEEK_V4_REASONING_MODELS = new Set([
8
+ "deepseek-v4-flash-fp8-mtp",
9
+ "deepseek-v4-flash-fp8-mtp-norefusal",
10
+ ]);
11
+ const QWEN_38_REASONING_MODELS = new Set(["qwen3.8-27b-refusal-dial"]);
12
+ const DEEPSEEK_V4_VARIANTS = {
13
+ off: { reasoningEffort: "none" },
14
+ low: { reasoningEffort: "low" },
15
+ high: { reasoningEffort: "high" },
16
+ max: { reasoningEffort: "max" },
17
+ };
18
+ const DEEPSEEK_V4_VARIANTS_V2 = [
19
+ { id: "off", body: { reasoning_effort: "none" } },
20
+ { id: "low", body: { reasoning_effort: "low" } },
21
+ { id: "high", body: { reasoning_effort: "high" } },
22
+ { id: "max", body: { reasoning_effort: "max" } },
23
+ ];
24
+ const QWEN_38_VARIANTS = {
25
+ off: { chat_template_kwargs: { enable_thinking: false } },
26
+ low: { reasoningEffort: "low" },
27
+ medium: { reasoningEffort: "medium" },
28
+ xhigh: { reasoningEffort: "xhigh" },
29
+ };
30
+ const QWEN_38_VARIANTS_V2 = [
31
+ { id: "off", body: { chat_template_kwargs: { enable_thinking: false } } },
32
+ { id: "low", body: { reasoning_effort: "low" } },
33
+ { id: "medium", body: { reasoning_effort: "medium" } },
34
+ { id: "xhigh", body: { reasoning_effort: "xhigh" } },
35
+ ];
36
+ const REASONING_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"];
37
+ const REASONING_EFFORT_SET = new Set(REASONING_EFFORTS);
7
38
  const NUMERIC_MODEL_INFO_FIELDS = new Set([
8
39
  "input_cost_per_token",
9
40
  "output_cost_per_token",
@@ -96,6 +127,9 @@ function enrichMetadata(existing, incoming, key) {
96
127
  }
97
128
  return merged;
98
129
  }
130
+ if (key === "supports_reasoning" && typeof existing === "boolean" && typeof incoming === "boolean") {
131
+ return existing && incoming;
132
+ }
99
133
  if (typeof existing === "boolean" && typeof incoming === "boolean")
100
134
  return existing || incoming;
101
135
  if (isValidMetadata(existing, key))
@@ -205,12 +239,12 @@ function v2Cost(model) {
205
239
  function limit(model) {
206
240
  const context = asNumber(model.model_info?.max_input_tokens) ?? asNumber(model.model_info?.max_tokens);
207
241
  const output = asNumber(model.model_info?.max_output_tokens) ?? asNumber(model.model_info?.max_tokens);
208
- if (context === undefined || output === undefined)
242
+ if (context === undefined)
209
243
  return undefined;
210
244
  return {
211
245
  context,
212
246
  ...(asNumber(model.model_info?.max_input_tokens) === undefined ? {} : { input: asNumber(model.model_info?.max_input_tokens) }),
213
- output,
247
+ output: output ?? 0,
214
248
  };
215
249
  }
216
250
  function canonicalModelName(model) {
@@ -240,10 +274,70 @@ function modalities(model) {
240
274
  };
241
275
  }
242
276
  function supportsReasoning(model) {
243
- return Boolean(model.model_info?.supports_reasoning ||
244
- model.model_info?.supported_params?.includes("reasoning_effort") ||
277
+ if (typeof model.model_info?.supports_reasoning === "boolean")
278
+ return model.model_info.supports_reasoning;
279
+ return Boolean(model.model_info?.supported_params?.includes("reasoning_effort") ||
245
280
  model.model_info?.supported_params?.includes("thinking"));
246
281
  }
282
+ export function isDeepSeekV4ReasoningModel(name) {
283
+ return name !== undefined && DEEPSEEK_V4_REASONING_MODELS.has(name);
284
+ }
285
+ export function isContractReasoningModel(name) {
286
+ return isDeepSeekV4ReasoningModel(name) || (name !== undefined && QWEN_38_REASONING_MODELS.has(name));
287
+ }
288
+ function enforceReasoningVariants(model) {
289
+ const name = asString(model.id) ?? asString(model.name);
290
+ if (!isContractReasoningModel(name))
291
+ return model;
292
+ return {
293
+ ...model,
294
+ reasoning: true,
295
+ options: { ...(isRecord(model.options) ? model.options : {}), variantDefault: false },
296
+ variants: isDeepSeekV4ReasoningModel(name) ? DEEPSEEK_V4_VARIANTS : QWEN_38_VARIANTS,
297
+ };
298
+ }
299
+ function enforceReasoningProviderModels(provider) {
300
+ return {
301
+ ...provider,
302
+ models: Object.fromEntries(Object.entries(provider.models).map(([name, model]) => [
303
+ name,
304
+ isContractReasoningModel(name) ? enforceReasoningVariants({ ...model, id: name }) : model,
305
+ ])),
306
+ };
307
+ }
308
+ export function addOpenCodeVariantTombstones(provider) {
309
+ return {
310
+ ...provider,
311
+ models: Object.fromEntries(Object.entries(provider.models).map(([name, model]) => [
312
+ name,
313
+ isContractReasoningModel(name)
314
+ ? {
315
+ ...model,
316
+ variants: {
317
+ ...(isDeepSeekV4ReasoningModel(name) ? DEEPSEEK_V4_VARIANTS : QWEN_38_VARIANTS),
318
+ ...(isDeepSeekV4ReasoningModel(name)
319
+ ? { medium: { disabled: true }, xhigh: { disabled: true } }
320
+ : {
321
+ none: { disabled: true },
322
+ minimal: { disabled: true },
323
+ high: { disabled: true },
324
+ max: { disabled: true },
325
+ }),
326
+ },
327
+ }
328
+ : model,
329
+ ])),
330
+ };
331
+ }
332
+ function reasoningVariants(model) {
333
+ const reported = new Set((model.model_info?.supported_reasoning_efforts ?? [])
334
+ .map((effort) => asString(effort)?.toLowerCase())
335
+ .filter((effort) => effort !== undefined && REASONING_EFFORT_SET.has(effort)));
336
+ const efforts = REASONING_EFFORTS.filter((effort) => reported.has(effort));
337
+ if (!efforts.length)
338
+ return undefined;
339
+ return Object.fromEntries(efforts.map((effort) => [effort, { reasoningEffort: effort }]));
340
+ }
247
341
  function generatedModel(model) {
248
342
  const name = modelName(model);
249
343
  if (!name)
@@ -258,6 +352,7 @@ function generatedModel(model) {
258
352
  modalities: mappedModalities,
259
353
  ...(mappedModalities.input.includes("image") || mappedModalities.input.includes("pdf") ? { attachment: true } : {}),
260
354
  ...(supportsReasoning(model) ? { reasoning: true } : {}),
355
+ ...(supportsReasoning(model) && reasoningVariants(model) ? { variants: reasoningVariants(model) } : {}),
261
356
  ...(model.model_info?.supports_function_calling || model.model_info?.supports_tool_choice ? { tool_call: true } : {}),
262
357
  };
263
358
  }
@@ -306,6 +401,7 @@ function v2Model(model, baseURL) {
306
401
  input: supportedModalities(model.modalities?.input ?? [TEXT_MODALITY]),
307
402
  output: supportedModalities(model.modalities?.output ?? [TEXT_MODALITY]),
308
403
  },
404
+ ...(isRecord(model.options) ? { options: { ...model.options } } : {}),
309
405
  ...(v2Cost(model) ? { cost: v2Cost(model) } : {}),
310
406
  ...(model.limit ? { limit: { ...model.limit } } : {}),
311
407
  ...(v2Variants(model) ? { variants: v2Variants(model) } : {}),
@@ -319,13 +415,16 @@ export function mapModels(response, options = {}) {
319
415
  const name = modelName(model);
320
416
  const generated = generatedModel(model);
321
417
  return name && generated
322
- ? [name, finalizedModel(deepMerge(generated, options.overrides?.[name]), options.overrides?.[name])]
418
+ ? [
419
+ name,
420
+ enforceReasoningVariants(finalizedModel(deepMerge(generated, options.overrides?.[name]), options.overrides?.[name])),
421
+ ]
323
422
  : undefined;
324
423
  })
325
424
  .filter((entry) => Boolean(entry)));
326
425
  }
327
426
  export function buildProvider(models, options = {}) {
328
- return deepMerge({
427
+ return enforceReasoningProviderModels(deepMerge({
329
428
  id: options.providerKey ?? "LiteLLM",
330
429
  name: options.providerName ?? "LiteLLM",
331
430
  npm: "@ai-sdk/openai-compatible",
@@ -336,14 +435,14 @@ export function buildProvider(models, options = {}) {
336
435
  ...(options.apiKey ? { body: { apiKey: options.apiKey } } : {}),
337
436
  },
338
437
  models: mapModels(models, options),
339
- }, options.providerOverrides);
438
+ }, options.providerOverrides));
340
439
  }
341
440
  export function mapModelsV2(models, options = {}) {
342
441
  const legacy = mapModels(models, options);
343
442
  return Object.fromEntries(Object.entries(legacy).map(([name, model]) => [name, v2Model(model, options.baseURL)]));
344
443
  }
345
444
  export function buildProviderV2(models, options = {}) {
346
- return deepMerge({
445
+ const provider = deepMerge({
347
446
  name: options.providerName ?? "LiteLLM",
348
447
  api: {
349
448
  type: "aisdk",
@@ -358,4 +457,24 @@ export function buildProviderV2(models, options = {}) {
358
457
  },
359
458
  models: mapModelsV2(models, options),
360
459
  }, options.providerOverrides);
460
+ return {
461
+ ...provider,
462
+ models: Object.fromEntries(Object.entries(provider.models).map(([name, model]) => [
463
+ name,
464
+ isContractReasoningModel(name)
465
+ ? {
466
+ ...model,
467
+ capabilities: {
468
+ tools: model.capabilities?.tools ?? false,
469
+ reasoning: true,
470
+ attachment: model.capabilities?.attachment ?? false,
471
+ input: model.capabilities?.input ?? [TEXT_MODALITY],
472
+ output: model.capabilities?.output ?? [TEXT_MODALITY],
473
+ },
474
+ options: { ...(isRecord(model.options) ? model.options : {}), variantDefault: false },
475
+ variants: isDeepSeekV4ReasoningModel(name) ? DEEPSEEK_V4_VARIANTS_V2 : QWEN_38_VARIANTS_V2,
476
+ }
477
+ : model,
478
+ ])),
479
+ };
361
480
  }
package/dist/types.d.ts CHANGED
@@ -20,6 +20,7 @@ export type OpenCodeModel = JsonObject & {
20
20
  attachment?: boolean;
21
21
  reasoning?: boolean;
22
22
  tool_call?: boolean;
23
+ variants?: Record<string, JsonObject>;
23
24
  };
24
25
  export type OpenCodeModelV2 = JsonObject & {
25
26
  family?: string;
@@ -120,6 +121,10 @@ export type LiteLLMModelInfo = JsonObject & {
120
121
  supports_vision?: boolean;
121
122
  supports_pdf_input?: boolean;
122
123
  supports_reasoning?: boolean;
124
+ supported_reasoning_efforts?: string[];
125
+ supports_low_reasoning_effort?: boolean;
126
+ supports_xhigh_reasoning_effort?: boolean;
127
+ supports_max_reasoning_effort?: boolean;
123
128
  supported_modalities?: string[];
124
129
  output_modalities?: string[];
125
130
  supported_params?: string[];
@@ -145,6 +150,7 @@ export type LiteLLMModelGroup = JsonObject & {
145
150
  supports_vision?: boolean;
146
151
  supports_pdf_input?: boolean;
147
152
  supports_reasoning?: boolean;
153
+ supported_reasoning_efforts?: string[];
148
154
  supported_modalities?: string[];
149
155
  output_modalities?: string[];
150
156
  supported_openai_params?: string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staticeng/opencode-litellm",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "OpenCode plugin that discovers LiteLLM models at runtime.",
5
5
  "type": "module",
6
6
  "license": "MIT",