@staticeng/opencode-litellm 0.1.9 → 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
@@ -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@0.1.9",
18
+ "@staticeng/opencode-litellm",
19
19
  {
20
20
  "baseURL": "https://litellm.example.com/v1",
21
21
  "apiKeyEnv": "LITELLM_API_KEY"
@@ -33,7 +33,7 @@ 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.
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
37
 
38
38
  ## Options
39
39
 
@@ -56,11 +56,9 @@ The LiteLLM key must have permission to call `GET /model/info` and root `GET /mo
56
56
 
57
57
  ## Overrides
58
58
 
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.
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
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.
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.
64
62
 
65
63
  ```json
66
64
  {
@@ -90,11 +88,22 @@ from model names; the LiteLLM catalog remains the source of truth.
90
88
  }
91
89
  ```
92
90
 
93
- 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` |
94
103
 
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.
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.
96
105
 
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.
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.
98
107
 
99
108
  ## Runtime Behavior
100
109
 
@@ -103,7 +112,8 @@ The exact model group `qwen3.8-27b-refusal-dial` similarly exposes only `off`, `
103
112
  - Adds or replaces both legacy `cfg.provider[providerKey]` and V2 `cfg.providers[providerKey]` during the config hook.
104
113
  - Keeps namespaced deployment IDs and augments them with exact bare model-group IDs; exact collisions are enriched deterministically.
105
114
  - 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.
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.
107
117
  - Warns safely and keeps deployment models when group discovery fails in non-strict mode; strict mode mutates neither config surface on failure.
108
118
  - Does not write `opencode.json`, cache model metadata, or persist generated provider data.
109
119
  - Emits the required `limit.output` as `0` when LiteLLM reports a context/input limit but no output
package/dist/helpers.d.ts CHANGED
File without changes
package/dist/helpers.js CHANGED
File without changes
package/dist/index.d.ts CHANGED
File without changes
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { fetchModelGroupInfo, fetchModelInfo } from "./litellm.js";
2
- import { addOpenCodeVariantTombstones, buildProvider, buildProviderV2, isContractReasoningModel, 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 } });
@@ -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 = addOpenCodeVariantTombstones(buildProvider(models, resolved));
28
+ const provider = buildProvider(models, resolved);
29
29
  const config = cfg;
30
30
  const providerV2 = buildProviderV2(models, resolved);
31
31
  cfg.provider = {
@@ -60,6 +60,11 @@ const LiteLLMPlugin = async (ctx, options) => {
60
60
  if (!isContractReasoningModel(input.model.id))
61
61
  return;
62
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
+ }
63
68
  }),
64
69
  };
65
70
  };
package/dist/litellm.d.ts CHANGED
File without changes
package/dist/litellm.js CHANGED
File without changes
package/dist/mapping.d.ts CHANGED
@@ -11,6 +11,7 @@ export declare function modelListResponseShape(value: unknown): ModelListRespons
11
11
  export declare function mergeModelInfo(deployments: LiteLLMModelInfo[], groups: LiteLLMModelInfo[]): LiteLLMModelInfo[];
12
12
  export declare function normalizeModelListResponse(response: unknown): LiteLLMModelInfo[];
13
13
  export declare function isDeepSeekV4ReasoningModel(name: string | undefined): boolean;
14
+ export declare function isQwen38ReasoningModel(name: string | undefined): boolean;
14
15
  export declare function isContractReasoningModel(name: string | undefined): boolean;
15
16
  export declare function addOpenCodeVariantTombstones(provider: OpenCodeProvider): OpenCodeProvider;
16
17
  export declare function mapModels(response: unknown, options?: LiteLLMPluginOptions): {
@@ -18,6 +19,48 @@ export declare function mapModels(response: unknown, options?: LiteLLMPluginOpti
18
19
  };
19
20
  export declare function buildProvider(models: unknown, options?: LiteLLMPluginOptions): OpenCodeProvider;
20
21
  export declare function mapModelsV2(models: unknown, options?: LiteLLMPluginOptions): {
21
- [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
+ };
22
65
  };
23
66
  export declare function buildProviderV2(models: unknown, options?: LiteLLMPluginOptions): OpenCodeProviderV2;
package/dist/mapping.js CHANGED
@@ -1,38 +1,10 @@
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"]);
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
8
  const REASONING_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"];
37
9
  const REASONING_EFFORT_SET = new Set(REASONING_EFFORTS);
38
10
  const NUMERIC_MODEL_INFO_FIELDS = new Set([
@@ -195,6 +167,8 @@ function allowedByFilters(model, options) {
195
167
  const name = modelName(model);
196
168
  if (!name)
197
169
  return false;
170
+ if (isRetiredModelAlias(name))
171
+ return false;
198
172
  if (options.includeModels?.length && !options.includeModels.includes(name))
199
173
  return false;
200
174
  if (options.excludeModels?.includes(name))
@@ -280,29 +254,35 @@ function supportsReasoning(model) {
280
254
  model.model_info?.supported_params?.includes("thinking"));
281
255
  }
282
256
  export function isDeepSeekV4ReasoningModel(name) {
283
- return name !== undefined && DEEPSEEK_V4_REASONING_MODELS.has(name);
257
+ return modelContract(name)?.family === "DeepSeek V4 Flash";
258
+ }
259
+ export function isQwen38ReasoningModel(name) {
260
+ return modelContract(name)?.family === "Qwen3.8";
284
261
  }
285
262
  export function isContractReasoningModel(name) {
286
- return isDeepSeekV4ReasoningModel(name) || (name !== undefined && QWEN_38_REASONING_MODELS.has(name));
263
+ return modelContract(name) !== undefined;
287
264
  }
288
- function enforceReasoningVariants(model) {
289
- const name = asString(model.id) ?? asString(model.name);
290
- if (!isContractReasoningModel(name))
291
- return model;
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
+ }
292
282
  return {
293
- ...model,
294
283
  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
- ])),
284
+ options: { ...defaultMode.legacy, variantDefault: entry.variantDefault },
285
+ variants,
306
286
  };
307
287
  }
308
288
  export function addOpenCodeVariantTombstones(provider) {
@@ -310,22 +290,7 @@ export function addOpenCodeVariantTombstones(provider) {
310
290
  ...provider,
311
291
  models: Object.fromEntries(Object.entries(provider.models).map(([name, model]) => [
312
292
  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,
293
+ isContractReasoningModel(name) ? deepMerge(contractDefaults(name, true) ?? {}, model) : model,
329
294
  ])),
330
295
  };
331
296
  }
@@ -338,12 +303,12 @@ function reasoningVariants(model) {
338
303
  return undefined;
339
304
  return Object.fromEntries(efforts.map((effort) => [effort, { reasoningEffort: effort }]));
340
305
  }
341
- function generatedModel(model) {
306
+ function generatedModel(model, tombstones = false) {
342
307
  const name = modelName(model);
343
308
  if (!name)
344
309
  return undefined;
345
310
  const mappedModalities = modalities(model);
346
- return {
311
+ const generated = {
347
312
  id: name,
348
313
  name,
349
314
  ...(asString(model.model_info?.base_model) ? { family: asString(model.model_info?.base_model) } : {}),
@@ -355,6 +320,7 @@ function generatedModel(model) {
355
320
  ...(supportsReasoning(model) && reasoningVariants(model) ? { variants: reasoningVariants(model) } : {}),
356
321
  ...(model.model_info?.supports_function_calling || model.model_info?.supports_tool_choice ? { tool_call: true } : {}),
357
322
  };
323
+ return deepMerge(generated, contractDefaults(name, tombstones));
358
324
  }
359
325
  function finalizedModel(model, override) {
360
326
  const input = supportedModalities(model.modalities?.input ?? [TEXT_MODALITY]);
@@ -366,19 +332,37 @@ function finalizedModel(model, override) {
366
332
  attachment: attachmentOverride ?? input.some((modality) => modality === "image" || modality === "pdf"),
367
333
  };
368
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
+ }
369
344
  function v2Variants(model) {
370
345
  const variants = model.variants;
371
346
  if (!isRecord(variants))
372
347
  return undefined;
373
- return Object.entries(variants).map(([id, value]) => {
374
- const raw = isRecord(value) ? { ...value } : {};
375
- const body = isRecord(raw.options) ? { ...raw.options } : raw;
376
- if (body.reasoningEffort !== undefined && body.reasoning_effort === undefined) {
377
- body.reasoning_effort = body.reasoningEffort;
378
- delete body.reasoningEffort;
379
- }
380
- return { id, body };
381
- });
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());
382
366
  }
383
367
  function v2Model(model, baseURL) {
384
368
  const id = asString(model.id) ?? asString(model.name);
@@ -407,24 +391,27 @@ function v2Model(model, baseURL) {
407
391
  ...(v2Variants(model) ? { variants: v2Variants(model) } : {}),
408
392
  };
409
393
  }
410
- export function mapModels(response, options = {}) {
394
+ function mapModelsInternal(response, options, tombstones) {
411
395
  const models = normalizeModelListResponse(response);
412
396
  return Object.fromEntries(models
413
397
  .filter((model) => allowedByFilters(model, options))
414
398
  .map((model) => {
415
399
  const name = modelName(model);
416
- const generated = generatedModel(model);
400
+ const generated = generatedModel(model, tombstones);
417
401
  return name && generated
418
402
  ? [
419
403
  name,
420
- enforceReasoningVariants(finalizedModel(deepMerge(generated, options.overrides?.[name]), options.overrides?.[name])),
404
+ finalizedModel(deepMerge(generated, options.overrides?.[name]), options.overrides?.[name]),
421
405
  ]
422
406
  : undefined;
423
407
  })
424
408
  .filter((entry) => Boolean(entry)));
425
409
  }
410
+ export function mapModels(response, options = {}) {
411
+ return mapModelsInternal(response, options, false);
412
+ }
426
413
  export function buildProvider(models, options = {}) {
427
- return enforceReasoningProviderModels(deepMerge({
414
+ return deepMerge({
428
415
  id: options.providerKey ?? "LiteLLM",
429
416
  name: options.providerName ?? "LiteLLM",
430
417
  npm: "@ai-sdk/openai-compatible",
@@ -434,15 +421,19 @@ export function buildProvider(models, options = {}) {
434
421
  apiKey: options.apiKey,
435
422
  ...(options.apiKey ? { body: { apiKey: options.apiKey } } : {}),
436
423
  },
437
- models: mapModels(models, options),
438
- }, options.providerOverrides));
424
+ models: mapModelsInternal(models, options, true),
425
+ }, options.providerOverrides);
439
426
  }
440
427
  export function mapModelsV2(models, options = {}) {
441
428
  const legacy = mapModels(models, options);
442
- 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
+ }));
443
434
  }
444
435
  export function buildProviderV2(models, options = {}) {
445
- const provider = deepMerge({
436
+ return deepMerge({
446
437
  name: options.providerName ?? "LiteLLM",
447
438
  api: {
448
439
  type: "aisdk",
@@ -457,24 +448,4 @@ export function buildProviderV2(models, options = {}) {
457
448
  },
458
449
  models: mapModelsV2(models, options),
459
450
  }, 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
- };
480
451
  }
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;
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.9",
3
+ "version": "0.2.0",
4
4
  "description": "OpenCode plugin that discovers LiteLLM models at runtime.",
5
5
  "type": "module",
6
6
  "license": "MIT",