@staticeng/opencode-litellm 0.3.2 → 0.3.4

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,20 +4,23 @@ Runtime LiteLLM model autodiscovery for OpenCode. The plugin fetches LiteLLM dep
4
4
 
5
5
  ## Install
6
6
 
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.
7
+ Install the package by npm name, then add it to OpenCode `plugin` config:
8
+
9
+ ```sh
10
+ npm install @staticeng/opencode-litellm
11
+ ```
9
12
 
10
13
  ```json
11
14
  {
12
15
  "$schema": "https://opencode.ai/config.json",
13
- "plugins": [
14
- {
15
- "package": "@staticeng/opencode-litellm@0.3.0",
16
- "options": {
16
+ "plugin": [
17
+ [
18
+ "@staticeng/opencode-litellm",
19
+ {
17
20
  "baseURL": "https://litellm.example.com/v1",
18
21
  "apiKeyEnv": "LITELLM_API_KEY"
19
22
  }
20
- }
23
+ ]
21
24
  ]
22
25
  }
23
26
  ```
@@ -32,15 +35,27 @@ Do not put real API keys in `opencode.json`. If you pass `apiKey` directly, the
32
35
 
33
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.
34
37
 
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.
38
+ ### OpenCode V2
39
+
40
+ OpenCode V2 loads plugins from `plugins` (plural). Use the package name as the entrypoint; its default export supports the V2 loader. The `./v2` export is also available for direct imports. The current API is tested against OpenCode V2 2.0.16 and `@opencode/plugin` 2.0.16; the transitional `@opencode-ai/plugin` catalog API remains supported. The dual V1/V2 default entrypoint supports V1 1.18.29 and newer; older V1 releases that require a default-exported function need an earlier package version.
41
+
42
+ ```json
43
+ {
44
+ "plugins": [
45
+ {
46
+ "package": "@staticeng/opencode-litellm",
47
+ "options": {
48
+ "baseURL": "https://litellm.example.com/v1",
49
+ "apiKeyEnv": "LITELLM_API_KEY"
50
+ }
51
+ }
52
+ ]
53
+ }
54
+ ```
55
+
56
+ V2 adds the provider and models through in-memory transforms. It uses the same options and environment variables as V1.
39
57
 
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.
58
+ Current V2 uses the drivers bundled in the OpenCode executable: `@opencode/ai/providers/openai-compatible` for Chat Completions and `@opencode/ai/providers/openai/responses` for Responses. GPT names and LiteLLM `mode: responses` metadata select Responses, including opaque aliases. Models marked `use_responses_lite: true` receive `reasoning.context: all_turns`. Explicit model overrides still win. OpenCode 2.0.16 does not bundle the SDK's `openai-compatible/responses` entrypoint; the plugin normalizes that override to the bundled Responses driver as well.
44
59
 
45
60
  ## Options
46
61
 
@@ -49,7 +64,7 @@ deliver it to each peer. Each host installs its own npm package cache.
49
64
  | `baseURL` | `LITELLM_BASE_URL` | LiteLLM proxy base URL. `/v1` URLs are supported. |
50
65
  | `apiKey` | env value | Bearer token for LiteLLM. |
51
66
  | `apiKeyEnv` | `LITELLM_API_KEY` | Environment variable used when `apiKey` is not set. |
52
- | `providerKey` | `LiteLLM` | Provider ID registered in OpenCode. |
67
+ | `providerKey` | `LiteLLM` | Provider ID in the V1 runtime config or V2 catalog. |
53
68
  | `providerName` | `LiteLLM` | Human provider name. |
54
69
  | `overrides` | `{}` | Per-model deep-merge overrides. |
55
70
  | `providerOverrides` | `{}` | Provider-level deep-merge overrides. |
@@ -58,40 +73,49 @@ deliver it to each peer. Each host installs its own npm package cache.
58
73
  | `includeBackends` | unset | Optional exact allowlist by `model_info.backend`, unioned with `includeModels` pins. |
59
74
  | `excludeModels` | none | Optional denylist by model name. |
60
75
  | `timeoutMs` | `30000` | Per-request discovery timeout. |
76
+ | `startupRetryMs` | `120000` | Startup grace period for transient DNS/network, timeout and HTTP 408/429/5xx failures. Uses exponential backoff up to 10 seconds. Set `0` to make one attempt. |
77
+ | `recoveryIntervalMs` | `30000` | Current V2 makes one background discovery attempt per interval after startup grace expires, then reloads the provider catalog when it recovers. Retries until success or plugin unload. Set `0` to disable. |
61
78
  | `strict` | `false` | Throw on fetch/mapping failures instead of warning. |
62
79
 
63
80
  The LiteLLM key must have permission to call `GET /model/info` and root `GET /model_group/info`.
64
81
 
82
+ ### Starting before the network is ready
83
+
84
+ At boot, a user service can start before DNS or the LiteLLM endpoint is reachable,
85
+ even with `After=network-online.target`. Transient failures now retry during the
86
+ startup grace period. In current OpenCode V2, non-strict mode also recovers after
87
+ that period without restarting OpenCode or OpenChamber: it registers the provider
88
+ and reloads the live catalog once discovery succeeds. Recovery timers and in-flight
89
+ requests stop when the plugin unloads. No generated model data is written to your
90
+ configuration. Authentication errors and invalid metadata fail immediately.
91
+
92
+ Strict mode throws if discovery still fails after startup grace. Legacy and
93
+ transitional APIs use startup retries; recovery after that grace period requires
94
+ the current V2 provider reload API.
95
+
65
96
  When `includeBackends` is present, models are candidates when `model_info.backend` exactly and case-sensitively matches an entry or their exact model name is pinned by `includeModels`. Backend values are not trimmed, normalized, or partially matched; missing and non-string values do not match. An empty `includeBackends` matches no backends, so without model pins it includes no models. `excludeModels` always wins over backend matches and pins, and candidates must still pass `includeModes` (or the default `chat` and `responses` modes). When `includeBackends` is omitted, `includeModels` retains its historical behavior: a non-empty array is a model-name allowlist, while an omitted or empty array imposes no model-name allowlist.
66
97
 
67
98
  ## Overrides
68
99
 
69
100
  LiteLLM metadata does not expose every OpenCode model field. The plugin includes exact built-in contracts for the currently approved GPT-5.4, GPT-5.5, GPT-5.6, GPT-6 Luna and Sol, DeepSeek V4 Flash, and Qwen3.8 aliases. Use `overrides` to customize those defaults or to add metadata for unknown models.
70
101
 
71
- When LiteLLM publishes `model_info.supported_reasoning_efforts`, the plugin maps that list to OpenCode variants automatically. Known contracts contribute their approved defaults; discovered extra efforts remain available unless that exact contract explicitly closes discovery. Currently only `qwen3.8-flash-next` closes discovery so unverified efforts cannot become active. No substring, suffix, provider namespace, or base-model inference is used.
102
+ When LiteLLM publishes `model_info.supported_reasoning_efforts`, the plugin maps that list to OpenCode variants automatically. Known contracts contribute their approved defaults; discovered extra efforts remain available unless that exact contract explicitly closes discovery. `qwen3.8-flash-next` and `gpt-6.1-sol` close discovery so unsupported efforts cannot become active. No substring, suffix, provider namespace, or base-model inference is used.
72
103
 
73
104
  ```json
74
105
  {
75
- "plugins": [
76
- {
77
- "package": "@staticeng/opencode-litellm@0.3.0",
78
- "options": {
106
+ "plugin": [
107
+ [
108
+ "@staticeng/opencode-litellm",
109
+ {
79
110
  "baseURL": "https://litellm.example.com/v1",
80
111
  "apiKeyEnv": "LITELLM_API_KEY",
81
112
  "overrides": {
82
113
  "chatgpt/gpt-5.5": {
83
- "limit": {
84
- "context": 262144,
85
- "output": 128000
86
- },
114
+ "limit": { "context": 262144, "output": 128000 },
87
115
  "variants": {
88
- "high": {
89
- "reasoningEffort": "high"
90
- }
116
+ "high": { "reasoningEffort": "high" }
91
117
  },
92
- "interleaved": {
93
- "field": "reasoning_details"
94
- }
118
+ "interleaved": { "field": "reasoning_details" }
95
119
  }
96
120
  },
97
121
  "providerOverrides": {
@@ -100,7 +124,7 @@ When LiteLLM publishes `model_info.supported_reasoning_efforts`, the plugin maps
100
124
  }
101
125
  }
102
126
  }
103
- }
127
+ ]
104
128
  ]
105
129
  }
106
130
  ```
@@ -140,25 +164,28 @@ The built-in mode matrix is:
140
164
  | GPT-5.5 | `none`, `low`, `medium`, `high`, `xhigh` | `medium` |
141
165
  | GPT-5.6 Luna / Sol / Terra | `none`, `low`, `medium`, `high`, `xhigh`, `max` | `medium` |
142
166
  | DeepSeek V4 Flash | `off`, `low`, `high`, `max` | `max` |
167
+ | DeepSeek V4.1 Flash 1017 | `off`, `low`, `high`, `max` | `high` |
143
168
  | Qwen3.8 27B refusal dial | `off`, `low`, `medium`, `xhigh` | `xhigh` |
144
169
  | Qwen3.8 Flash Next | `off`, `low`, `medium`, `xhigh` | `xhigh` |
145
170
 
146
- 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. Flash Next activates exactly `off`, `low`, `medium`, and `xhigh`: discovered extras such as `ultra` are suppressed, V2 omits `minimal`, `high`, and `max`, and the legacy OpenCode surface disables unsupported generic choices. 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 model's default effort before serialization. The `variantDefault` compatibility marker is UI-only and is stripped before requests for exact built-in aliases.
171
+ 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. Flash Next activates exactly `off`, `low`, `medium`, and `xhigh`: discovered extras such as `ultra` are suppressed, V2 omits `minimal`, `high`, and `max`, and the legacy OpenCode surface disables unsupported generic choices. V1 supplies the contract default through model options and removes the default effort in its `chat.params` hook for Qwen `off`. Transitional V2 selects the default with `model.request.variant`. Current V2 applies the selected default variant's body and headers to outbound requests when no explicit variant is selected; an explicit `off` variant therefore has no inherited default effort. A request-level reasoning effort takes precedence. The `variantDefault` compatibility marker applies only to V1 and is stripped before requests for exact built-in aliases.
147
172
 
148
173
  The exact dynamically discovered alias `qwen3.8-flash-next` also receives verified static metadata that overrides incomplete or conflicting LiteLLM group metadata: text and image input, text output, attachment, reasoning, tool calling, and a 262144-token context. Its output limit remains unknown and is represented by the package's `0` sentinel; pricing and other modalities are not supplied. The contract is still discovery-gated and never creates the alias when LiteLLM omits it.
149
174
 
175
+ The exact alias `deepseek-v4.1-flash-1017` receives verified static metadata from the official model card and deployed LiteLLM metadata: text and image input, text output, tool calling, a 1048576-token context, and a 262144-token output limit. Pricing remains discovery-owned. The V4.1 encoder documents `chat` and `thinking` modes with `low`, `high`, and `max` effort aliases; the plugin exposes `off` as the LiteLLM/vLLM `reasoning_effort=none` compatibility mode. Only `off`, `low`, `high`, and `max` are active, defaulting to `high`; extra discovery efforts are suppressed. The contract remains discovery-gated.
176
+
150
177
  Normal GPT-5.3 Codex and GPT-5.3 Codex Spark aliases are retired and filtered even if stale discovery metadata still reports them. Unlisted aliases such as `defend/gpt-5.5` do not inherit a contract.
151
178
 
152
179
  ## Runtime Behavior
153
180
 
154
181
  - Fetches `${baseURL}/model/info`, then root `/model_group/info`, with separate authenticated and timed requests.
155
182
  - Preserves `/v1` for model-info discovery but resolves model-group discovery from the LiteLLM origin.
156
- - Registers the provider and models with the native V2 provider transform API; V1 uses the config hook.
183
+ - V1 updates `cfg.provider[providerKey]` and the migration-era `cfg.providers[providerKey]` in memory; transitional V2 registers a catalog transform, while current V2 registers a provider transform and scoped session hooks.
157
184
  - Keeps namespaced deployment IDs and augments them with exact bare model-group IDs; exact collisions are enriched deterministically.
158
185
  - Skips malformed group rows without a nonblank `model_group` and exposes reasoning capability on legacy and V2 model metadata.
159
186
  - Applies exact built-in model contracts as defaults, then applies explicit model and provider overrides last.
160
187
  - Filters retired normal and Spark GPT-5.3 Codex aliases without redirecting them.
161
- - Warns safely and keeps deployment models when group discovery fails in non-strict mode; strict mode mutates neither config surface on failure.
188
+ - Warns safely and keeps deployment models when group discovery fails in non-strict mode; strict mode skips registration or config mutation on failure.
162
189
  - Does not write `opencode.json`, cache model metadata, or persist generated provider data.
163
190
  - Emits the required `limit.output` as `0` when LiteLLM reports a context/input limit but no output
164
191
  limit. The zero sentinel retains known metadata without inventing a positive output allowance.
@@ -176,9 +203,11 @@ npm test
176
203
  npm pack --dry-run
177
204
  ```
178
205
 
206
+ With the OpenCode 2.0.16 executable installed, run `OPENCODE_V2_CLI_TEST=1 node --test test/v2-cli.test.mjs` after building. Set `OPENCODE_V2_CLI` to override the executable path. This isolated test uses mock HTTP endpoints and temporary configuration to check the binary's provider loading, Responses aliases, and Qwen reasoning modes without real credentials.
207
+
179
208
  Confirm package contents before publishing and keep secrets out of fixtures, docs, logs, and config examples.
180
209
 
181
- The GitHub release workflow supports npm trusted publishing from the `npm-production` environment. Provenance
210
+ Publishing uses npm trusted publishing from the `npm-production` GitHub environment. Provenance
182
211
  requires this GitHub repository to remain public. Before a release, configure npm's trusted
183
212
  publisher for package `@staticeng/opencode-litellm` with this repository, workflow
184
213
  `publish.yml`, and environment `npm-production`; no npm token is used by the workflow.
@@ -0,0 +1,5 @@
1
+ import type { ResolvedOptions } from "./options.js";
2
+ export declare function isTransient(error: unknown): boolean;
3
+ export declare function waitForRetry(ms: number, signal: AbortSignal): Promise<void>;
4
+ /** Retry only transient failures; the grace deadline includes requests and backoff. */
5
+ export declare function discoverWithRetry<T>(load: (options: ResolvedOptions, signal: AbortSignal) => Promise<T>, options: ResolvedOptions, signal?: AbortSignal): Promise<T>;
@@ -0,0 +1,33 @@
1
+ export function isTransient(error) {
2
+ return error instanceof Error && "retryable" in error && error.retryable === true;
3
+ }
4
+ export function waitForRetry(ms, signal) {
5
+ return new Promise((resolve, reject) => {
6
+ if (signal.aborted)
7
+ return reject(new Error("LiteLLM discovery cancelled"));
8
+ const abort = () => { clearTimeout(timer); reject(new Error("LiteLLM discovery cancelled")); };
9
+ const timer = setTimeout(() => { signal.removeEventListener("abort", abort); resolve(); }, ms);
10
+ signal.addEventListener("abort", abort, { once: true });
11
+ });
12
+ }
13
+ /** Retry only transient failures; the grace deadline includes requests and backoff. */
14
+ export async function discoverWithRetry(load, options, signal = new AbortController().signal) {
15
+ const grace = Number.isFinite(options.startupRetryMs) ? Math.max(0, options.startupRetryMs) : 120_000;
16
+ const deadline = Date.now() + grace;
17
+ let delay = 1000;
18
+ for (;;) {
19
+ if (signal.aborted)
20
+ throw new Error("LiteLLM discovery cancelled");
21
+ try {
22
+ const remaining = deadline - Date.now();
23
+ return await load({ ...options, timeoutMs: grace > 0 ? Math.max(1, Math.min(options.timeoutMs, remaining)) : options.timeoutMs }, signal);
24
+ }
25
+ catch (error) {
26
+ const remaining = deadline - Date.now();
27
+ if (!isTransient(error) || signal.aborted || remaining <= 0)
28
+ throw error;
29
+ await waitForRetry(Math.min(delay, remaining), signal);
30
+ delay = Math.min(delay * 2, 10_000);
31
+ }
32
+ }
33
+ }
package/dist/index.d.ts CHANGED
@@ -34,6 +34,6 @@ declare const _default: {
34
34
  options: Record<string, any>;
35
35
  }) => Promise<void>;
36
36
  }>;
37
- setup: (ctx: import("@opencode/plugin/promise/plugin").Context) => Promise<void>;
37
+ setup: (ctx: import("@opencode/plugin/promise/plugin").Context) => Promise<() => void>;
38
38
  };
39
39
  export default _default;
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@ 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
4
  import V2Plugin from "./v2.js";
5
+ import { discoverWithRetry } from "./discovery.js";
5
6
  async function log(ctx, level, message, extra = {}) {
6
7
  await ctx.client?.app?.log?.({ body: { service: "opencode-litellm", level, message, extra } });
7
8
  }
@@ -10,7 +11,7 @@ export const LiteLLMPlugin = async (ctx, options) => {
10
11
  return {
11
12
  config: async (cfg) => {
12
13
  try {
13
- const deployments = await fetchModelInfo(resolved);
14
+ const deployments = await discoverWithRetry((attempt, signal) => fetchModelInfo(attempt, fetch, signal), resolved);
14
15
  let groups = [];
15
16
  try {
16
17
  groups = await fetchModelGroupInfo(resolved);
package/dist/litellm.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type ResolvedOptions } from "./options.js";
2
2
  import type { LiteLLMModelInfo } from "./types.js";
3
- export declare function fetchModelInfo(options: ResolvedOptions, fetchImpl?: typeof fetch): Promise<LiteLLMModelInfo[]>;
3
+ export declare function fetchModelInfo(options: ResolvedOptions, fetchImpl?: typeof fetch, signal?: AbortSignal): Promise<LiteLLMModelInfo[]>;
4
4
  export declare function normalizeModelGroupResponse(response: unknown): LiteLLMModelInfo[];
5
- export declare function fetchModelGroupInfo(options: ResolvedOptions, fetchImpl?: typeof fetch): Promise<LiteLLMModelInfo[]>;
5
+ export declare function fetchModelGroupInfo(options: ResolvedOptions, fetchImpl?: typeof fetch, signal?: AbortSignal): Promise<LiteLLMModelInfo[]>;
package/dist/litellm.js CHANGED
@@ -6,8 +6,12 @@ function requireCredentials(options) {
6
6
  if (!options.apiKey)
7
7
  throw new Error("opencode-litellm requires options.apiKey or the configured apiKeyEnv");
8
8
  }
9
- async function fetchJSON(options, endpoint, url, fetchImpl) {
9
+ async function fetchJSON(options, endpoint, url, fetchImpl, signal) {
10
10
  const controller = new AbortController();
11
+ const abort = () => controller.abort();
12
+ if (signal?.aborted)
13
+ abort();
14
+ signal?.addEventListener("abort", abort, { once: true });
11
15
  const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
12
16
  try {
13
17
  try {
@@ -20,24 +24,30 @@ async function fetchJSON(options, endpoint, url, fetchImpl) {
20
24
  signal: controller.signal,
21
25
  });
22
26
  if (!response.ok)
23
- throw new Error(`LiteLLM ${endpoint} returned HTTP ${response.status}`);
27
+ throw Object.assign(new Error(`LiteLLM ${endpoint} returned HTTP ${response.status}`), {
28
+ retryable: response.status === 408 || response.status === 429 || response.status >= 500,
29
+ });
24
30
  return await response.json();
25
31
  }
26
32
  catch (error) {
27
33
  if (error instanceof Error && error.message.startsWith(`LiteLLM ${endpoint}`))
28
34
  throw error;
29
35
  if (controller.signal.aborted)
30
- throw new Error(`LiteLLM ${endpoint} timed out after ${options.timeoutMs}ms`);
31
- throw new Error(`LiteLLM ${endpoint} request failed: ${error instanceof Error ? error.message : String(error)}`);
36
+ throw Object.assign(new Error(`LiteLLM ${endpoint} timed out after ${options.timeoutMs}ms`), { retryable: !signal?.aborted });
37
+ throw Object.assign(new Error(`LiteLLM ${endpoint} request failed: ${error instanceof Error ? error.message : String(error)}`), {
38
+ // A JSON/parser failure is a catalog error, not a network outage.
39
+ retryable: !(error instanceof SyntaxError),
40
+ });
32
41
  }
33
42
  }
34
43
  finally {
35
44
  clearTimeout(timeout);
45
+ signal?.removeEventListener("abort", abort);
36
46
  }
37
47
  }
38
- export async function fetchModelInfo(options, fetchImpl = fetch) {
48
+ export async function fetchModelInfo(options, fetchImpl = fetch, signal) {
39
49
  requireCredentials(options);
40
- return normalizeModelListResponse(await fetchJSON(options, "/model/info", modelInfoURL(options.baseURL), fetchImpl));
50
+ return normalizeModelListResponse(await fetchJSON(options, "/model/info", modelInfoURL(options.baseURL), fetchImpl, signal));
41
51
  }
42
52
  export function normalizeModelGroupResponse(response) {
43
53
  const data = typeof response === "object" && response !== null && !Array.isArray(response)
@@ -63,7 +73,7 @@ export function normalizeModelGroupResponse(response) {
63
73
  },
64
74
  }));
65
75
  }
66
- export async function fetchModelGroupInfo(options, fetchImpl = fetch) {
76
+ export async function fetchModelGroupInfo(options, fetchImpl = fetch, signal) {
67
77
  requireCredentials(options);
68
- return normalizeModelGroupResponse(await fetchJSON(options, "/model_group/info", modelGroupInfoURL(options.baseURL), fetchImpl));
78
+ return normalizeModelGroupResponse(await fetchJSON(options, "/model_group/info", modelGroupInfoURL(options.baseURL), fetchImpl, signal));
69
79
  }
package/dist/mapping.d.ts CHANGED
@@ -18,7 +18,7 @@ export declare function mapModels(response: unknown, options?: LiteLLMPluginOpti
18
18
  [k: string]: OpenCodeModel;
19
19
  };
20
20
  export declare function buildProvider(models: unknown, options?: LiteLLMPluginOptions): OpenCodeProvider;
21
- export declare function mapModelsV2(models: unknown, options?: LiteLLMPluginOptions): {
21
+ export declare function mapModelsV2(models: unknown, options?: LiteLLMPluginOptions, nativeImages?: boolean): {
22
22
  [k: string]: OpenCodeModelV2 | {
23
23
  variants: {
24
24
  id: string;
@@ -26,6 +26,7 @@ export declare function mapModelsV2(models: unknown, options?: LiteLLMPluginOpti
26
26
  [x: string]: unknown;
27
27
  };
28
28
  }[];
29
+ type?: "image";
29
30
  family?: string;
30
31
  name?: string;
31
32
  api?: {
@@ -63,4 +64,4 @@ export declare function mapModelsV2(models: unknown, options?: LiteLLMPluginOpti
63
64
  };
64
65
  };
65
66
  };
66
- export declare function buildProviderV2(models: unknown, options?: LiteLLMPluginOptions): OpenCodeProviderV2;
67
+ export declare function buildProviderV2(models: unknown, options?: LiteLLMPluginOptions, nativeImages?: boolean): OpenCodeProviderV2;
package/dist/mapping.js CHANGED
@@ -167,7 +167,7 @@ export function normalizeModelListResponse(response) {
167
167
  function mode(model) {
168
168
  return asString(model.model_info?.mode) ?? "chat";
169
169
  }
170
- function allowedByFilters(model, options) {
170
+ function allowedByFilters(model, options, nativeImages = false) {
171
171
  const name = modelName(model);
172
172
  if (!name)
173
173
  return false;
@@ -186,8 +186,13 @@ function allowedByFilters(model, options) {
186
186
  if (options.excludeModels?.includes(name))
187
187
  return false;
188
188
  const modelMode = mode(model);
189
+ // Images require the typed catalog and media resolver introduced in current V2.
190
+ if (modelMode === "image_generation" && !nativeImages)
191
+ return false;
189
192
  if (options.includeModes?.length)
190
193
  return options.includeModes.includes(modelMode);
194
+ if (modelMode === "image_generation")
195
+ return nativeImages;
191
196
  return DEFAULT_MODES.has(modelMode) && !NON_CHAT_MODES.has(modelMode);
192
197
  }
193
198
  function cost(model) {
@@ -283,11 +288,13 @@ function contractDefaults(name, tombstones = false) {
283
288
  throw new Error(`Model contract ${entry.family} has no default mode body`);
284
289
  const variants = Object.fromEntries(entry.modes.map((mode) => [mode.id, { ...mode.legacy }]));
285
290
  if (tombstones) {
286
- const disabled = entry.family === "DeepSeek V4 Flash"
287
- ? ["medium", "xhigh"]
288
- : entry.family === "Qwen3.8"
289
- ? ["none", "minimal", "low", "medium", "high", "xhigh", "max", ...(entry.metadata ? ["ultra"] : [])].filter((effort) => !entry.modes.some((mode) => mode.id === effort))
290
- : [];
291
+ const disabled = entry.family === "DeepSeek V4.1 Flash"
292
+ ? REASONING_EFFORTS.filter((effort) => !entry.modes.some((mode) => mode.id === effort))
293
+ : entry.family === "DeepSeek V4 Flash"
294
+ ? ["medium", "xhigh"]
295
+ : entry.family === "Qwen3.8"
296
+ ? ["none", "minimal", "low", "medium", "high", "xhigh", "max", ...(entry.metadata ? ["ultra"] : [])].filter((effort) => !entry.modes.some((mode) => mode.id === effort))
297
+ : [];
291
298
  for (const effort of disabled)
292
299
  variants[effort] = { disabled: true };
293
300
  }
@@ -330,6 +337,15 @@ function generatedModel(model, tombstones = false) {
330
337
  const name = modelName(model);
331
338
  if (!name)
332
339
  return undefined;
340
+ if (mode(model) === "image_generation")
341
+ return {
342
+ id: name,
343
+ name,
344
+ type: "image",
345
+ provider: { npm: "@opencode/ai/providers/openai-compatible/images" },
346
+ modalities: { input: supportedModalities(["text", ...(model.model_info?.supported_modalities ?? [])]), output: ["image"] },
347
+ tool_call: false,
348
+ };
333
349
  const mappedModalities = modalities(model);
334
350
  const discoveredVariants = supportsReasoning(model) ? reasoningVariants(model) : undefined;
335
351
  const suppressDiscoveredVariants = modelContract(name)?.suppressDiscoveredVariants === true;
@@ -403,6 +419,7 @@ function v2Model(model, baseURL) {
403
419
  const name = asString(model.name);
404
420
  const family = asString(model.family);
405
421
  return {
422
+ ...(model.type === "image" ? { type: "image" } : {}),
406
423
  ...(family ? { family } : {}),
407
424
  ...(name ? { name } : {}),
408
425
  api: {
@@ -425,10 +442,10 @@ function v2Model(model, baseURL) {
425
442
  ...(v2Variants(model) ? { variants: v2Variants(model) } : {}),
426
443
  };
427
444
  }
428
- function mapModelsInternal(response, options, tombstones) {
445
+ function mapModelsInternal(response, options, tombstones, nativeImages = false) {
429
446
  const models = normalizeModelListResponse(response);
430
447
  return Object.fromEntries(models
431
- .filter((model) => allowedByFilters(model, options))
448
+ .filter((model) => allowedByFilters(model, options, nativeImages))
432
449
  .map((model) => {
433
450
  const name = modelName(model);
434
451
  const generated = generatedModel(model, tombstones);
@@ -458,8 +475,8 @@ export function buildProvider(models, options = {}) {
458
475
  models: mapModelsInternal(models, options, true),
459
476
  }, options.providerOverrides);
460
477
  }
461
- export function mapModelsV2(models, options = {}) {
462
- const legacy = mapModels(models, options);
478
+ export function mapModelsV2(models, options = {}, nativeImages = false) {
479
+ const legacy = mapModelsInternal(models, options, false, nativeImages);
463
480
  return Object.fromEntries(Object.entries(legacy).map(([name, model]) => {
464
481
  const mapped = v2Model(model, options.baseURL);
465
482
  const variants = contractV2Variants(name, model, options.overrides?.[name]);
@@ -471,7 +488,7 @@ export function mapModelsV2(models, options = {}) {
471
488
  const overridesEffort = isRecord(overriddenOptions) && ("reasoningEffort" in overriddenOptions ||
472
489
  "reasoning_effort" in overriddenOptions ||
473
490
  "chat_template_kwargs" in overriddenOptions);
474
- // V2 applies the selected variant after request.body, so Qwen off must not inherit its default effort.
491
+ // V2 applies the selected variant after request.body, so the Qwen off mode cannot inherit a default effort.
475
492
  if (contract && mapped.api?.package !== "@ai-sdk/openai" && !overridesEffort) {
476
493
  delete defaultBody.reasoning_effort;
477
494
  delete defaultBody.reasoningEffort;
@@ -504,7 +521,7 @@ function responsesBody(options) {
504
521
  }
505
522
  return body;
506
523
  }
507
- export function buildProviderV2(models, options = {}) {
524
+ export function buildProviderV2(models, options = {}, nativeImages = false) {
508
525
  return deepMerge({
509
526
  name: options.providerName ?? "LiteLLM",
510
527
  api: {
@@ -518,6 +535,6 @@ export function buildProviderV2(models, options = {}) {
518
535
  ...(options.apiKey ? { apiKey: options.apiKey } : {}),
519
536
  },
520
537
  },
521
- models: mapModelsV2(models, options),
538
+ models: mapModelsV2(models, options, nativeImages),
522
539
  }, options.providerOverrides);
523
540
  }
@@ -28,12 +28,24 @@ function contract(family, aliases, modes, defaultMode, metadata, suppressDiscove
28
28
  }
29
29
  const GPT_STANDARD_MODES = effortModes(["none", "low", "medium", "high", "xhigh"]);
30
30
  const GPT_MAX_MODES = effortModes(["none", "low", "medium", "high", "xhigh", "max"]);
31
+ const GPT_61_SOL_MODES = effortModes(["low", "medium", "high", "xhigh", "max"]);
31
32
  const DEEPSEEK_MODES = Object.freeze([
32
33
  Object.freeze({ id: "off", legacy: freezeJson({ reasoningEffort: "none" }), v2Body: freezeJson({ reasoning_effort: "none" }) }),
33
34
  Object.freeze({ id: "low", legacy: freezeJson({ reasoningEffort: "low" }), v2Body: freezeJson({ reasoning_effort: "low" }) }),
34
35
  Object.freeze({ id: "high", legacy: freezeJson({ reasoningEffort: "high" }), v2Body: freezeJson({ reasoning_effort: "high" }) }),
35
36
  Object.freeze({ id: "max", legacy: freezeJson({ reasoningEffort: "max" }), v2Body: freezeJson({ reasoning_effort: "max" }) }),
36
37
  ]);
38
+ // V4.1's official encoder accepts chat mode plus low/high/max effort aliases;
39
+ // the plugin keeps the existing off -> none wire contract for LiteLLM/vLLM.
40
+ const DEEPSEEK_V41_METADATA = Object.freeze({
41
+ modalities: Object.freeze({
42
+ input: Object.freeze(["text", "image"]),
43
+ output: Object.freeze(["text"]),
44
+ }),
45
+ attachment: true,
46
+ tool_call: true,
47
+ limit: Object.freeze({ context: 1048576, output: 262144 }),
48
+ });
37
49
  const qwenOff = Object.freeze({ enable_thinking: false });
38
50
  const QWEN_MODES = Object.freeze([
39
51
  Object.freeze({
@@ -78,8 +90,9 @@ export const MODEL_CONTRACTS = Object.freeze([
78
90
  contract("GPT-5.6 Sol", chatGptAliases("gpt-5.6-sol"), GPT_MAX_MODES, "medium"),
79
91
  contract("GPT-5.6 Terra", chatGptAliases("gpt-5.6-terra"), GPT_MAX_MODES, "medium"),
80
92
  contract("GPT-6 Luna", chatGptAliases("gpt-6-luna"), GPT_MAX_MODES, "medium"),
81
- contract("GPT-6 Sol", chatGptAliases("gpt-6-sol"), GPT_MAX_MODES, "medium"),
93
+ contract("GPT-6.1 Sol", chatGptAliases("gpt-6.1-sol"), GPT_61_SOL_MODES, "medium", undefined, true),
82
94
  contract("DeepSeek V4 Flash", ["deepseek-v4-flash-fp8-mtp", "deepseek-v4-flash-fp8-mtp-norefusal"], DEEPSEEK_MODES, "max"),
95
+ contract("DeepSeek V4.1 Flash", ["deepseek-v4.1-flash-1017"], DEEPSEEK_MODES, "high", DEEPSEEK_V41_METADATA, true),
83
96
  contract("Qwen3.8", ["qwen3.8-27b-refusal-dial"], QWEN_MODES, "xhigh"),
84
97
  contract("Qwen3.8", ["qwen3.8-flash-next"], QWEN_MODES, "xhigh", QWEN_FLASH_NEXT_METADATA, true),
85
98
  contract("Qwen3.8 Strata", ["5090-local-model", "5090-local-model-codex"], STRATA_QWEN_MODES, "xhigh", STRATA_QWEN_METADATA, true),
package/dist/options.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { LiteLLMPluginOptions } from "./types.js";
2
- export type ResolvedOptions = Required<Pick<LiteLLMPluginOptions, "providerKey" | "providerName" | "timeoutMs" | "strict">> & Omit<LiteLLMPluginOptions, "providerKey" | "providerName" | "timeoutMs" | "strict">;
2
+ export type ResolvedOptions = Required<Pick<LiteLLMPluginOptions, "providerKey" | "providerName" | "timeoutMs" | "strict" | "startupRetryMs" | "recoveryIntervalMs">> & Omit<LiteLLMPluginOptions, "providerKey" | "providerName" | "timeoutMs" | "strict" | "startupRetryMs" | "recoveryIntervalMs">;
3
3
  export declare function resolveOptions(options?: LiteLLMPluginOptions): ResolvedOptions;
4
4
  export declare function modelInfoURL(baseURL: string): string;
5
5
  export declare function modelGroupInfoURL(baseURL: string): string;
package/dist/options.js CHANGED
@@ -12,6 +12,8 @@ export function resolveOptions(options = {}) {
12
12
  providerKey: options.providerKey ?? "LiteLLM",
13
13
  providerName: options.providerName ?? "LiteLLM",
14
14
  timeoutMs: options.timeoutMs ?? 30_000,
15
+ startupRetryMs: options.startupRetryMs ?? 120_000,
16
+ recoveryIntervalMs: options.recoveryIntervalMs ?? 30_000,
15
17
  strict: options.strict ?? false,
16
18
  };
17
19
  }
package/dist/types.d.ts CHANGED
@@ -27,6 +27,7 @@ export type ModelContract = Readonly<{
27
27
  metadata?: ModelContractMetadata;
28
28
  }>;
29
29
  export type OpenCodeModel = JsonObject & {
30
+ type?: "image";
30
31
  provider?: {
31
32
  npm?: string;
32
33
  api?: string;
@@ -54,6 +55,7 @@ export type OpenCodeModel = JsonObject & {
54
55
  variants?: Record<string, JsonObject>;
55
56
  };
56
57
  export type OpenCodeModelV2 = JsonObject & {
58
+ type?: "image";
57
59
  family?: string;
58
60
  name?: string;
59
61
  api?: {
@@ -131,6 +133,10 @@ export type LiteLLMPluginOptions = {
131
133
  /** Exact model_info.backend matches, unioned with includeModels pins. */
132
134
  includeBackends?: string[];
133
135
  timeoutMs?: number;
136
+ /** Total startup grace period for transient discovery failures. Zero disables retries. */
137
+ startupRetryMs?: number;
138
+ /** V2 recovery interval after startup grace expires. Zero disables background recovery. */
139
+ recoveryIntervalMs?: number;
134
140
  strict?: boolean;
135
141
  };
136
142
  export type LiteLLMModelInfo = JsonObject & {
package/dist/v2.d.ts CHANGED
@@ -2,6 +2,6 @@ import type { Plugin as CurrentPlugin } from "@opencode/plugin";
2
2
  type CurrentContext = Parameters<CurrentPlugin.Plugin["setup"]>[0];
3
3
  declare const LiteLLMPluginV2: {
4
4
  id: string;
5
- setup: (ctx: CurrentContext) => Promise<void>;
5
+ setup: (ctx: CurrentContext) => Promise<() => void>;
6
6
  };
7
7
  export default LiteLLMPluginV2;
package/dist/v2.js CHANGED
@@ -2,9 +2,17 @@ import { fetchModelGroupInfo, fetchModelInfo } from "./litellm.js";
2
2
  import { buildProviderV2, mergeModelInfo } from "./mapping.js";
3
3
  import { deepMerge } from "./merge.js";
4
4
  import { resolveOptions } from "./options.js";
5
+ import { discoverWithRetry, isTransient } from "./discovery.js";
5
6
  const CHAT_PACKAGE = "@opencode/ai/providers/openai-compatible";
6
7
  // The 2.0.16 binary bundles this driver, but not openai-compatible/responses.
7
8
  const RESPONSES_PACKAGE = "@opencode/ai/providers/openai/responses";
9
+ /** Image catalog types and their resolver are available in OpenCode 2.2+. */
10
+ function supportsNativeImages(ctx) {
11
+ const version = ctx.app?.version?.match(/^v?(\d+)\.(\d+)\.(\d+)(?:$|[-+])/);
12
+ if (!version)
13
+ return false;
14
+ return Number(version[1]) > 2 || (Number(version[1]) === 2 && Number(version[2]) >= 2);
15
+ }
8
16
  function packageName(name) {
9
17
  if (name === "@ai-sdk/openai" || name === "@opencode/ai/providers/openai-compatible/responses")
10
18
  return RESPONSES_PACKAGE;
@@ -49,6 +57,7 @@ function currentModel(providerID, id, config, providerPackage) {
49
57
  }
50
58
  return {
51
59
  id,
60
+ ...(config.type === "image" ? { type: "image" } : {}),
52
61
  modelID: config.api?.id ?? id,
53
62
  providerID,
54
63
  name: config.name ?? id,
@@ -226,62 +235,85 @@ const LiteLLMPluginV2 = {
226
235
  id: "opencode-litellm",
227
236
  setup: async (ctx) => {
228
237
  const options = resolveOptions(ctx.options);
229
- let provider;
230
- try {
231
- const deployments = await fetchModelInfo(options);
232
- let groups = [];
238
+ const controller = new AbortController();
239
+ let timer;
240
+ let recovered = false;
241
+ const cleanup = () => { controller.abort(); if (timer)
242
+ clearTimeout(timer); };
243
+ const load = async () => {
244
+ let provider;
233
245
  try {
234
- groups = await fetchModelGroupInfo(options);
246
+ const deployments = await discoverWithRetry((attempt, signal) => fetchModelInfo(attempt, fetch, signal), recovered ? { ...options, startupRetryMs: 0 } : options, controller.signal);
247
+ let groups = [];
248
+ try {
249
+ groups = await fetchModelGroupInfo(options, fetch, controller.signal);
250
+ }
251
+ catch (error) {
252
+ if (options.strict)
253
+ throw error;
254
+ console.warn("opencode-litellm: failed to load LiteLLM model group metadata", error instanceof Error ? error.message : String(error));
255
+ }
256
+ const models = mergeModelInfo(deployments, groups);
257
+ if (ctx.provider?.transform) {
258
+ const overrides = { ...options.overrides };
259
+ for (const model of models) {
260
+ if (model.model_info?.mode !== "responses")
261
+ continue;
262
+ const id = model.model_name ?? model.litellm_params?.model ?? model.model_info?.id;
263
+ if (!id)
264
+ continue;
265
+ // Opaque Responses aliases need the same transport as GPT-named models.
266
+ overrides[id] = deepMerge({ provider: { npm: "@ai-sdk/openai" } }, overrides[id]);
267
+ }
268
+ provider = buildProviderV2(models, { ...options, overrides }, supportsNativeImages(ctx));
269
+ for (const model of models) {
270
+ if (model.model_info?.use_responses_lite !== true)
271
+ continue;
272
+ const id = model.model_name ?? model.litellm_params?.model ?? model.model_info?.id;
273
+ const mapped = id ? provider.models[id] : undefined;
274
+ if (!mapped || currentModelPackage(mapped, currentProviderPackage(provider)) !== RESPONSES_PACKAGE)
275
+ continue;
276
+ mapped.request ??= {};
277
+ mapped.request.body = deepMerge({ reasoning: { context: "all_turns" } }, mapped.request.body);
278
+ }
279
+ }
280
+ else {
281
+ provider = buildProviderV2(models, options);
282
+ }
235
283
  }
236
284
  catch (error) {
285
+ if (controller.signal.aborted)
286
+ return;
237
287
  if (options.strict)
238
288
  throw error;
239
- console.warn("opencode-litellm: failed to load LiteLLM model group metadata", error instanceof Error ? error.message : String(error));
289
+ console.warn("opencode-litellm: failed to load LiteLLM provider metadata", error instanceof Error ? error.message : String(error));
290
+ const interval = options.recoveryIntervalMs;
291
+ if (isTransient(error) && Number.isFinite(interval) && interval > 0 && ctx.provider?.reload) {
292
+ timer = setTimeout(() => {
293
+ recovered = true;
294
+ void load().catch((failure) => console.warn("opencode-litellm: catalog recovery failed", failure instanceof Error ? failure.message : String(failure)));
295
+ }, interval);
296
+ timer.unref?.();
297
+ }
298
+ return;
240
299
  }
241
- const models = mergeModelInfo(deployments, groups);
300
+ if (controller.signal.aborted)
301
+ return;
242
302
  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
- }
303
+ await registerCurrent(ctx, options.providerKey, provider);
304
+ if (recovered)
305
+ await ctx.provider.reload();
306
+ return;
264
307
  }
265
- else {
266
- provider = buildProviderV2(models, options);
308
+ const transitional = ctx;
309
+ if (transitional.catalog?.transform) {
310
+ await transitional.catalog.transform((catalog) => applyTransitional(catalog, options.providerKey, provider));
311
+ return;
267
312
  }
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");
313
+ throw new Error("opencode-litellm requires an OpenCode V2 provider or catalog transform API");
314
+ };
315
+ await load();
316
+ return cleanup;
285
317
  },
286
318
  };
287
319
  export default LiteLLMPluginV2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staticeng/opencode-litellm",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "OpenCode plugin that discovers LiteLLM models at runtime.",
5
5
  "type": "module",
6
6
  "license": "MIT",