@staticeng/opencode-litellm 0.1.8 → 0.2.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/LICENSE CHANGED
File without changes
package/README.md CHANGED
@@ -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 should use the published package name 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 |
@@ -54,7 +56,9 @@ The LiteLLM key must have permission to call `GET /model/info` and root `GET /mo
54
56
 
55
57
  ## Overrides
56
58
 
57
- 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.
59
+ LiteLLM metadata does not expose every OpenCode model field. Version 0.2.0 includes exact built-in contracts for the currently approved GPT-5.4, GPT-5.5, GPT-5.6, GPT-5.3 Codex Spark, DeepSeek V4 Flash, and Qwen3.8 aliases. Use `overrides` to customize those defaults or to add metadata for unknown models.
60
+
61
+ When LiteLLM publishes `model_info.supported_reasoning_efforts`, the plugin maps that list to OpenCode variants automatically for unknown models. Known contracts use exact alias membership and win over absent, incomplete, or conflicting discovery metadata. No substring, suffix, provider namespace, or base-model inference is used.
58
62
 
59
63
  ```json
60
64
  {
@@ -68,7 +72,7 @@ LiteLLM metadata does not expose every OpenCode model field. Use `overrides` to
68
72
  "chatgpt/gpt-5.5": {
69
73
  "limit": { "context": 262144, "output": 128000 },
70
74
  "variants": {
71
- "high": { "options": { "reasoning_effort": "high" } }
75
+ "high": { "reasoningEffort": "high" }
72
76
  },
73
77
  "interleaved": { "field": "reasoning_details" }
74
78
  }
@@ -84,7 +88,22 @@ LiteLLM metadata does not expose every OpenCode model field. Use `overrides` to
84
88
  }
85
89
  ```
86
90
 
87
- Overrides win over generated values through a recursive deep merge. Arrays and scalar values are replaced rather than concatenated.
91
+ Overrides win over built-in contracts and generated values through a recursive deep merge. Model overrides are applied first and provider overrides last. Arrays and scalar values are replaced rather than concatenated.
92
+
93
+ The built-in mode matrix is:
94
+
95
+ | Contract | Modes in order | Default |
96
+ | --- | --- | --- |
97
+ | GPT-5.4 / GPT-5.4 Mini | `none`, `low`, `medium`, `high`, `xhigh` | `none` |
98
+ | GPT-5.5 | `none`, `low`, `medium`, `high`, `xhigh` | `medium` |
99
+ | GPT-5.6 Luna / Sol / Terra | `none`, `low`, `medium`, `high`, `xhigh`, `max` | `medium` |
100
+ | GPT-5.3 Codex Spark | `none`, `low`, `medium`, `high`, `xhigh` | `high` |
101
+ | DeepSeek V4 Flash | `off`, `low`, `high`, `max` | `max` |
102
+ | Qwen3.8 | `off`, `low`, `medium`, `xhigh` | `xhigh` |
103
+
104
+ DeepSeek `off` sends canonical `none`. Qwen `off` sends only `chat_template_kwargs.enable_thinking=false`, with no reasoning effort. `ultra` is not generated by a built-in contract. For requests without an explicit named variant, the plugin supplies the row's contract default through model options; an explicit variant is merged afterward and wins. Qwen's explicit `off` cleanup removes the default `xhigh` effort before serialization. The `variantDefault` compatibility marker is UI-only and is stripped before requests for exact built-in aliases.
105
+
106
+ Normal GPT-5.3 Codex aliases are retired and filtered even if stale discovery metadata still reports them. GPT-5.3 Codex Spark is a separate exact contract and remains available whenever discovered. Unlisted aliases such as `defend/gpt-5.5` do not inherit a contract.
88
107
 
89
108
  ## Runtime Behavior
90
109
 
@@ -93,8 +112,12 @@ Overrides win over generated values through a recursive deep merge. Arrays and s
93
112
  - Adds or replaces both legacy `cfg.provider[providerKey]` and V2 `cfg.providers[providerKey]` during the config hook.
94
113
  - Keeps namespaced deployment IDs and augments them with exact bare model-group IDs; exact collisions are enriched deterministically.
95
114
  - Skips malformed group rows without a nonblank `model_group` and exposes reasoning capability on legacy and V2 model metadata.
115
+ - Applies exact built-in model contracts as defaults, then applies explicit model and provider overrides last.
116
+ - Filters retired normal GPT-5.3 Codex aliases without redirecting them; discovered Spark aliases remain independent.
96
117
  - Warns safely and keeps deployment models when group discovery fails in non-strict mode; strict mode mutates neither config surface on failure.
97
118
  - Does not write `opencode.json`, cache model metadata, or persist generated provider data.
119
+ - Emits the required `limit.output` as `0` when LiteLLM reports a context/input limit but no output
120
+ limit. The zero sentinel retains known metadata without inventing a positive output allowance.
98
121
  - Includes `chat` and `responses` modes by default; non-chat modes such as `embedding` and `rerank` are excluded unless explicitly included.
99
122
  - No HuggingFace fallback is included in v1.
100
123
 
@@ -110,3 +133,8 @@ npm pack --dry-run
110
133
  ```
111
134
 
112
135
  Confirm package contents before publishing and keep secrets out of fixtures, docs, logs, and config examples.
136
+
137
+ Publishing uses npm trusted publishing from the `npm-production` GitHub environment. Provenance
138
+ requires this GitHub repository to remain public. Before a release, configure npm's trusted
139
+ publisher for package `@staticeng/opencode-litellm` with this repository, workflow
140
+ `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 { buildProvider, buildProviderV2, isContractReasoningModel, isQwen38ReasoningModel, 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 } });
@@ -53,6 +53,19 @@ 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
+ const selectedVariant = input.message?.model?.variant;
64
+ if (isQwen38ReasoningModel(input.model.id) && selectedVariant === "off") {
65
+ delete output.options.reasoningEffort;
66
+ delete output.options.reasoning_effort;
67
+ }
68
+ }),
56
69
  };
57
70
  };
58
71
  export default LiteLLMPlugin;
package/dist/litellm.d.ts CHANGED
File without changes
package/dist/litellm.js CHANGED
File without changes
package/dist/mapping.d.ts CHANGED
@@ -10,11 +10,57 @@ 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 isQwen38ReasoningModel(name: string | undefined): boolean;
15
+ export declare function isContractReasoningModel(name: string | undefined): boolean;
16
+ export declare function addOpenCodeVariantTombstones(provider: OpenCodeProvider): OpenCodeProvider;
13
17
  export declare function mapModels(response: unknown, options?: LiteLLMPluginOptions): {
14
18
  [k: string]: OpenCodeModel;
15
19
  };
16
20
  export declare function buildProvider(models: unknown, options?: LiteLLMPluginOptions): OpenCodeProvider;
17
21
  export declare function mapModelsV2(models: unknown, options?: LiteLLMPluginOptions): {
18
- [k: string]: OpenCodeModelV2;
22
+ [k: string]: OpenCodeModelV2 | {
23
+ variants: {
24
+ id: string;
25
+ body: {
26
+ [x: string]: unknown;
27
+ };
28
+ }[];
29
+ family?: string;
30
+ name?: string;
31
+ api?: {
32
+ id?: string;
33
+ type: "aisdk";
34
+ package: string;
35
+ url?: string;
36
+ settings?: import("./types.js").JsonObject;
37
+ };
38
+ capabilities?: {
39
+ tools: boolean;
40
+ reasoning: boolean;
41
+ attachment: boolean;
42
+ input: string[];
43
+ output: string[];
44
+ };
45
+ request?: {
46
+ headers?: Record<string, string>;
47
+ body?: import("./types.js").JsonObject;
48
+ variant?: string;
49
+ };
50
+ cost?: {
51
+ input: number;
52
+ output: number;
53
+ cache?: {
54
+ read?: number;
55
+ write?: number;
56
+ };
57
+ };
58
+ disabled?: boolean;
59
+ limit?: {
60
+ context?: number;
61
+ input?: number;
62
+ output?: number;
63
+ };
64
+ };
19
65
  };
20
66
  export declare function buildProviderV2(models: unknown, options?: LiteLLMPluginOptions): OpenCodeProviderV2;
package/dist/mapping.js CHANGED
@@ -1,9 +1,12 @@
1
1
  import { deepMerge } from "./merge.js";
2
+ import { isRetiredModelAlias, modelContract } from "./model-contracts.js";
2
3
  const DEFAULT_MODES = new Set(["chat", "responses"]);
3
4
  const NON_CHAT_MODES = new Set(["embedding", "rerank"]);
4
5
  const TEXT_MODALITY = "text";
5
6
  const OPENCODE_MODALITIES = new Set(["text", "audio", "image", "video", "pdf"]);
6
7
  const TEXT_ONLY_MODELS = new Set(["minimax-m2.7", "minimax_m2.7"]);
8
+ const REASONING_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"];
9
+ const REASONING_EFFORT_SET = new Set(REASONING_EFFORTS);
7
10
  const NUMERIC_MODEL_INFO_FIELDS = new Set([
8
11
  "input_cost_per_token",
9
12
  "output_cost_per_token",
@@ -96,6 +99,9 @@ function enrichMetadata(existing, incoming, key) {
96
99
  }
97
100
  return merged;
98
101
  }
102
+ if (key === "supports_reasoning" && typeof existing === "boolean" && typeof incoming === "boolean") {
103
+ return existing && incoming;
104
+ }
99
105
  if (typeof existing === "boolean" && typeof incoming === "boolean")
100
106
  return existing || incoming;
101
107
  if (isValidMetadata(existing, key))
@@ -161,6 +167,8 @@ function allowedByFilters(model, options) {
161
167
  const name = modelName(model);
162
168
  if (!name)
163
169
  return false;
170
+ if (isRetiredModelAlias(name))
171
+ return false;
164
172
  if (options.includeModels?.length && !options.includeModels.includes(name))
165
173
  return false;
166
174
  if (options.excludeModels?.includes(name))
@@ -205,12 +213,12 @@ function v2Cost(model) {
205
213
  function limit(model) {
206
214
  const context = asNumber(model.model_info?.max_input_tokens) ?? asNumber(model.model_info?.max_tokens);
207
215
  const output = asNumber(model.model_info?.max_output_tokens) ?? asNumber(model.model_info?.max_tokens);
208
- if (context === undefined || output === undefined)
216
+ if (context === undefined)
209
217
  return undefined;
210
218
  return {
211
219
  context,
212
220
  ...(asNumber(model.model_info?.max_input_tokens) === undefined ? {} : { input: asNumber(model.model_info?.max_input_tokens) }),
213
- output,
221
+ output: output ?? 0,
214
222
  };
215
223
  }
216
224
  function canonicalModelName(model) {
@@ -240,16 +248,67 @@ function modalities(model) {
240
248
  };
241
249
  }
242
250
  function supportsReasoning(model) {
243
- return Boolean(model.model_info?.supports_reasoning ||
244
- model.model_info?.supported_params?.includes("reasoning_effort") ||
251
+ if (typeof model.model_info?.supports_reasoning === "boolean")
252
+ return model.model_info.supports_reasoning;
253
+ return Boolean(model.model_info?.supported_params?.includes("reasoning_effort") ||
245
254
  model.model_info?.supported_params?.includes("thinking"));
246
255
  }
247
- function generatedModel(model) {
256
+ export function isDeepSeekV4ReasoningModel(name) {
257
+ return modelContract(name)?.family === "DeepSeek V4 Flash";
258
+ }
259
+ export function isQwen38ReasoningModel(name) {
260
+ return modelContract(name)?.family === "Qwen3.8";
261
+ }
262
+ export function isContractReasoningModel(name) {
263
+ return modelContract(name) !== undefined;
264
+ }
265
+ function contractDefaults(name, tombstones = false) {
266
+ const entry = modelContract(name);
267
+ if (!entry)
268
+ return undefined;
269
+ const defaultMode = entry.modes.find((mode) => mode.id === entry.defaultMode);
270
+ if (!defaultMode)
271
+ throw new Error(`Model contract ${entry.family} has no default mode body`);
272
+ const variants = Object.fromEntries(entry.modes.map((mode) => [mode.id, { ...mode.legacy }]));
273
+ if (tombstones) {
274
+ const disabled = entry.family === "DeepSeek V4 Flash"
275
+ ? ["medium", "xhigh"]
276
+ : entry.family === "Qwen3.8"
277
+ ? ["none", "minimal", "high", "max"]
278
+ : [];
279
+ for (const effort of disabled)
280
+ variants[effort] = { disabled: true };
281
+ }
282
+ return {
283
+ reasoning: true,
284
+ options: { ...defaultMode.legacy, variantDefault: entry.variantDefault },
285
+ variants,
286
+ };
287
+ }
288
+ export function addOpenCodeVariantTombstones(provider) {
289
+ return {
290
+ ...provider,
291
+ models: Object.fromEntries(Object.entries(provider.models).map(([name, model]) => [
292
+ name,
293
+ isContractReasoningModel(name) ? deepMerge(contractDefaults(name, true) ?? {}, model) : model,
294
+ ])),
295
+ };
296
+ }
297
+ function reasoningVariants(model) {
298
+ const reported = new Set((model.model_info?.supported_reasoning_efforts ?? [])
299
+ .map((effort) => asString(effort)?.toLowerCase())
300
+ .filter((effort) => effort !== undefined && REASONING_EFFORT_SET.has(effort)));
301
+ const efforts = REASONING_EFFORTS.filter((effort) => reported.has(effort));
302
+ if (!efforts.length)
303
+ return undefined;
304
+ return Object.fromEntries(efforts.map((effort) => [effort, { reasoningEffort: effort }]));
305
+ }
306
+ function generatedModel(model, tombstones = false) {
248
307
  const name = modelName(model);
249
308
  if (!name)
250
309
  return undefined;
251
310
  const mappedModalities = modalities(model);
252
- return {
311
+ const generated = {
253
312
  id: name,
254
313
  name,
255
314
  ...(asString(model.model_info?.base_model) ? { family: asString(model.model_info?.base_model) } : {}),
@@ -258,8 +317,10 @@ function generatedModel(model) {
258
317
  modalities: mappedModalities,
259
318
  ...(mappedModalities.input.includes("image") || mappedModalities.input.includes("pdf") ? { attachment: true } : {}),
260
319
  ...(supportsReasoning(model) ? { reasoning: true } : {}),
320
+ ...(supportsReasoning(model) && reasoningVariants(model) ? { variants: reasoningVariants(model) } : {}),
261
321
  ...(model.model_info?.supports_function_calling || model.model_info?.supports_tool_choice ? { tool_call: true } : {}),
262
322
  };
323
+ return deepMerge(generated, contractDefaults(name, tombstones));
263
324
  }
264
325
  function finalizedModel(model, override) {
265
326
  const input = supportedModalities(model.modalities?.input ?? [TEXT_MODALITY]);
@@ -271,19 +332,37 @@ function finalizedModel(model, override) {
271
332
  attachment: attachmentOverride ?? input.some((modality) => modality === "image" || modality === "pdf"),
272
333
  };
273
334
  }
335
+ function v2VariantBody(value) {
336
+ const raw = isRecord(value) ? { ...value } : {};
337
+ const body = isRecord(raw.options) ? { ...raw.options } : raw;
338
+ if (body.reasoningEffort !== undefined && body.reasoning_effort === undefined) {
339
+ body.reasoning_effort = body.reasoningEffort;
340
+ delete body.reasoningEffort;
341
+ }
342
+ return body;
343
+ }
274
344
  function v2Variants(model) {
275
345
  const variants = model.variants;
276
346
  if (!isRecord(variants))
277
347
  return undefined;
278
- return Object.entries(variants).map(([id, value]) => {
279
- const raw = isRecord(value) ? { ...value } : {};
280
- const body = isRecord(raw.options) ? { ...raw.options } : raw;
281
- if (body.reasoningEffort !== undefined && body.reasoning_effort === undefined) {
282
- body.reasoning_effort = body.reasoningEffort;
283
- delete body.reasoningEffort;
284
- }
285
- return { id, body };
286
- });
348
+ return Object.entries(variants).map(([id, value]) => ({ id, body: v2VariantBody(value) }));
349
+ }
350
+ function contractV2Variants(name, override) {
351
+ const entry = modelContract(name);
352
+ if (!entry)
353
+ return undefined;
354
+ const variants = entry.modes.map((mode) => ({ id: mode.id, body: { ...mode.v2Body } }));
355
+ if (!isRecord(override?.variants))
356
+ return variants;
357
+ const byID = new Map(variants.map((variant) => [variant.id, variant]));
358
+ for (const [id, value] of Object.entries(override.variants)) {
359
+ const current = byID.get(id);
360
+ byID.set(id, {
361
+ id,
362
+ body: deepMerge(current?.body ?? {}, v2VariantBody(value)),
363
+ });
364
+ }
365
+ return Array.from(byID.values());
287
366
  }
288
367
  function v2Model(model, baseURL) {
289
368
  const id = asString(model.id) ?? asString(model.name);
@@ -306,24 +385,31 @@ function v2Model(model, baseURL) {
306
385
  input: supportedModalities(model.modalities?.input ?? [TEXT_MODALITY]),
307
386
  output: supportedModalities(model.modalities?.output ?? [TEXT_MODALITY]),
308
387
  },
388
+ ...(isRecord(model.options) ? { options: { ...model.options } } : {}),
309
389
  ...(v2Cost(model) ? { cost: v2Cost(model) } : {}),
310
390
  ...(model.limit ? { limit: { ...model.limit } } : {}),
311
391
  ...(v2Variants(model) ? { variants: v2Variants(model) } : {}),
312
392
  };
313
393
  }
314
- export function mapModels(response, options = {}) {
394
+ function mapModelsInternal(response, options, tombstones) {
315
395
  const models = normalizeModelListResponse(response);
316
396
  return Object.fromEntries(models
317
397
  .filter((model) => allowedByFilters(model, options))
318
398
  .map((model) => {
319
399
  const name = modelName(model);
320
- const generated = generatedModel(model);
400
+ const generated = generatedModel(model, tombstones);
321
401
  return name && generated
322
- ? [name, finalizedModel(deepMerge(generated, options.overrides?.[name]), options.overrides?.[name])]
402
+ ? [
403
+ name,
404
+ finalizedModel(deepMerge(generated, options.overrides?.[name]), options.overrides?.[name]),
405
+ ]
323
406
  : undefined;
324
407
  })
325
408
  .filter((entry) => Boolean(entry)));
326
409
  }
410
+ export function mapModels(response, options = {}) {
411
+ return mapModelsInternal(response, options, false);
412
+ }
327
413
  export function buildProvider(models, options = {}) {
328
414
  return deepMerge({
329
415
  id: options.providerKey ?? "LiteLLM",
@@ -335,12 +421,16 @@ export function buildProvider(models, options = {}) {
335
421
  apiKey: options.apiKey,
336
422
  ...(options.apiKey ? { body: { apiKey: options.apiKey } } : {}),
337
423
  },
338
- models: mapModels(models, options),
424
+ models: mapModelsInternal(models, options, true),
339
425
  }, options.providerOverrides);
340
426
  }
341
427
  export function mapModelsV2(models, options = {}) {
342
428
  const legacy = mapModels(models, options);
343
- return Object.fromEntries(Object.entries(legacy).map(([name, model]) => [name, v2Model(model, options.baseURL)]));
429
+ return Object.fromEntries(Object.entries(legacy).map(([name, model]) => {
430
+ const mapped = v2Model(model, options.baseURL);
431
+ const variants = contractV2Variants(name, options.overrides?.[name]);
432
+ return [name, variants ? { ...mapped, variants } : mapped];
433
+ }));
344
434
  }
345
435
  export function buildProviderV2(models, options = {}) {
346
436
  return deepMerge({
package/dist/merge.d.ts CHANGED
File without changes
package/dist/merge.js CHANGED
File without changes
@@ -0,0 +1,5 @@
1
+ import type { ModelContract } from "./types.js";
2
+ export declare const MODEL_CONTRACTS: readonly ModelContract[];
3
+ export declare const RETIRED_MODEL_ALIASES: readonly string[];
4
+ export declare function modelContract(name: string | undefined): ModelContract | undefined;
5
+ export declare function isRetiredModelAlias(name: string | undefined): boolean;
@@ -0,0 +1,82 @@
1
+ function freezeJson(value) {
2
+ for (const nested of Object.values(value)) {
3
+ if (nested && typeof nested === "object" && !Object.isFrozen(nested))
4
+ Object.freeze(nested);
5
+ }
6
+ return Object.freeze(value);
7
+ }
8
+ function effortModes(ids) {
9
+ return Object.freeze(ids.map((id) => Object.freeze({
10
+ id,
11
+ legacy: freezeJson({ reasoningEffort: id }),
12
+ v2Body: freezeJson({ reasoning_effort: id }),
13
+ })));
14
+ }
15
+ function contract(family, aliases, modes, defaultMode) {
16
+ if (!modes.some((mode) => mode.id === defaultMode))
17
+ throw new Error(`Model contract ${family} has an invalid default`);
18
+ return Object.freeze({
19
+ family,
20
+ aliases: Object.freeze([...aliases]),
21
+ modes,
22
+ defaultMode,
23
+ reasoning: true,
24
+ variantDefault: false,
25
+ });
26
+ }
27
+ const GPT_STANDARD_MODES = effortModes(["none", "low", "medium", "high", "xhigh"]);
28
+ const GPT_MAX_MODES = effortModes(["none", "low", "medium", "high", "xhigh", "max"]);
29
+ const DEEPSEEK_MODES = Object.freeze([
30
+ Object.freeze({ id: "off", legacy: freezeJson({ reasoningEffort: "none" }), v2Body: freezeJson({ reasoning_effort: "none" }) }),
31
+ Object.freeze({ id: "low", legacy: freezeJson({ reasoningEffort: "low" }), v2Body: freezeJson({ reasoning_effort: "low" }) }),
32
+ Object.freeze({ id: "high", legacy: freezeJson({ reasoningEffort: "high" }), v2Body: freezeJson({ reasoning_effort: "high" }) }),
33
+ Object.freeze({ id: "max", legacy: freezeJson({ reasoningEffort: "max" }), v2Body: freezeJson({ reasoning_effort: "max" }) }),
34
+ ]);
35
+ const qwenOff = Object.freeze({ enable_thinking: false });
36
+ const QWEN_MODES = Object.freeze([
37
+ Object.freeze({
38
+ id: "off",
39
+ legacy: freezeJson({ chat_template_kwargs: qwenOff }),
40
+ v2Body: freezeJson({ chat_template_kwargs: qwenOff }),
41
+ }),
42
+ Object.freeze({ id: "low", legacy: freezeJson({ reasoningEffort: "low" }), v2Body: freezeJson({ reasoning_effort: "low" }) }),
43
+ Object.freeze({ id: "medium", legacy: freezeJson({ reasoningEffort: "medium" }), v2Body: freezeJson({ reasoning_effort: "medium" }) }),
44
+ Object.freeze({ id: "xhigh", legacy: freezeJson({ reasoningEffort: "xhigh" }), v2Body: freezeJson({ reasoning_effort: "xhigh" }) }),
45
+ ]);
46
+ const chatGptAliases = (model) => Object.freeze([
47
+ model,
48
+ `chatgpt/${model}`,
49
+ `chatgpt-account2/${model}`,
50
+ `chatgpt-account3/${model}`,
51
+ ]);
52
+ export const MODEL_CONTRACTS = Object.freeze([
53
+ contract("GPT-5.4", chatGptAliases("gpt-5.4"), GPT_STANDARD_MODES, "none"),
54
+ contract("GPT-5.4 Mini", chatGptAliases("gpt-5.4-mini"), GPT_STANDARD_MODES, "none"),
55
+ contract("GPT-5.5", chatGptAliases("gpt-5.5"), GPT_STANDARD_MODES, "medium"),
56
+ contract("GPT-5.6 Luna", chatGptAliases("gpt-5.6-luna"), GPT_MAX_MODES, "medium"),
57
+ contract("GPT-5.6 Sol", chatGptAliases("gpt-5.6-sol"), GPT_MAX_MODES, "medium"),
58
+ contract("GPT-5.6 Terra", chatGptAliases("gpt-5.6-terra"), GPT_MAX_MODES, "medium"),
59
+ contract("GPT-5.3 Codex Spark", chatGptAliases("gpt-5.3-codex-spark"), GPT_STANDARD_MODES, "high"),
60
+ contract("DeepSeek V4 Flash", ["deepseek-v4-flash-fp8-mtp", "deepseek-v4-flash-fp8-mtp-norefusal"], DEEPSEEK_MODES, "max"),
61
+ contract("Qwen3.8", ["qwen3.8-27b-refusal-dial"], QWEN_MODES, "xhigh"),
62
+ ]);
63
+ export const RETIRED_MODEL_ALIASES = Object.freeze(chatGptAliases("gpt-5.3-codex"));
64
+ const contractsByAlias = new Map();
65
+ for (const entry of MODEL_CONTRACTS) {
66
+ for (const alias of entry.aliases) {
67
+ if (contractsByAlias.has(alias))
68
+ throw new Error(`Duplicate model contract alias: ${alias}`);
69
+ contractsByAlias.set(alias, entry);
70
+ }
71
+ }
72
+ const retiredAliases = new Set(RETIRED_MODEL_ALIASES);
73
+ for (const alias of retiredAliases) {
74
+ if (contractsByAlias.has(alias))
75
+ throw new Error(`Active and retired model contract alias collision: ${alias}`);
76
+ }
77
+ export function modelContract(name) {
78
+ return name === undefined ? undefined : contractsByAlias.get(name);
79
+ }
80
+ export function isRetiredModelAlias(name) {
81
+ return name !== undefined && retiredAliases.has(name);
82
+ }
package/dist/options.d.ts CHANGED
File without changes
package/dist/options.js CHANGED
File without changes
package/dist/types.d.ts CHANGED
@@ -1,4 +1,17 @@
1
1
  export type JsonObject = Record<string, unknown>;
2
+ export type ModelContractMode = Readonly<{
3
+ id: string;
4
+ legacy: Readonly<JsonObject>;
5
+ v2Body: Readonly<JsonObject>;
6
+ }>;
7
+ export type ModelContract = Readonly<{
8
+ family: string;
9
+ aliases: readonly string[];
10
+ modes: readonly ModelContractMode[];
11
+ defaultMode: string;
12
+ reasoning: true;
13
+ variantDefault: false;
14
+ }>;
2
15
  export type OpenCodeModel = JsonObject & {
3
16
  id?: string;
4
17
  name?: string;
@@ -20,6 +33,7 @@ export type OpenCodeModel = JsonObject & {
20
33
  attachment?: boolean;
21
34
  reasoning?: boolean;
22
35
  tool_call?: boolean;
36
+ variants?: Record<string, JsonObject>;
23
37
  };
24
38
  export type OpenCodeModelV2 = JsonObject & {
25
39
  family?: string;
@@ -120,6 +134,10 @@ export type LiteLLMModelInfo = JsonObject & {
120
134
  supports_vision?: boolean;
121
135
  supports_pdf_input?: boolean;
122
136
  supports_reasoning?: boolean;
137
+ supported_reasoning_efforts?: string[];
138
+ supports_low_reasoning_effort?: boolean;
139
+ supports_xhigh_reasoning_effort?: boolean;
140
+ supports_max_reasoning_effort?: boolean;
123
141
  supported_modalities?: string[];
124
142
  output_modalities?: string[];
125
143
  supported_params?: string[];
@@ -145,6 +163,7 @@ export type LiteLLMModelGroup = JsonObject & {
145
163
  supports_vision?: boolean;
146
164
  supports_pdf_input?: boolean;
147
165
  supports_reasoning?: boolean;
166
+ supported_reasoning_efforts?: string[];
148
167
  supported_modalities?: string[];
149
168
  output_modalities?: string[];
150
169
  supported_openai_params?: string[];
package/dist/types.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staticeng/opencode-litellm",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
4
4
  "description": "OpenCode plugin that discovers LiteLLM models at runtime.",
5
5
  "type": "module",
6
6
  "license": "MIT",