@staticeng/opencode-litellm 0.3.3 → 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/LICENSE +0 -0
- package/README.md +65 -40
- package/dist/discovery.d.ts +5 -0
- package/dist/discovery.js +33 -0
- package/dist/helpers.d.ts +0 -0
- package/dist/helpers.js +0 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -1
- package/dist/litellm.d.ts +2 -2
- package/dist/litellm.js +18 -8
- package/dist/mapping.d.ts +0 -0
- package/dist/mapping.js +8 -6
- package/dist/merge.d.ts +0 -0
- package/dist/merge.js +0 -0
- package/dist/model-contracts.d.ts +0 -0
- package/dist/model-contracts.js +14 -1
- package/dist/options.d.ts +1 -1
- package/dist/options.js +2 -0
- package/dist/types.d.ts +4 -0
- package/dist/types.js +0 -0
- package/dist/v2.d.ts +1 -1
- package/dist/v2.js +71 -47
- package/package.json +1 -1
package/LICENSE
CHANGED
|
File without changes
|
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
|
-
|
|
8
|
-
|
|
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
|
-
"
|
|
14
|
-
|
|
15
|
-
"
|
|
16
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
package
|
|
38
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
76
|
-
|
|
77
|
-
"
|
|
78
|
-
|
|
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.
|
|
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
|
-
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
@@ -188,7 +217,3 @@ publisher for package `@staticeng/opencode-litellm` with this repository, workfl
|
|
|
188
217
|
The exact discovered aliases `5090-local-model` and `5090-local-model-codex` use the Qwen3.8 Flash Next Strata contract: text/image input, text output, reasoning, tools, and 262144 tokens of shared context. The output ceiling is 262144 within that shared window; it is not an additional output budget. Pricing continues to come from LiteLLM metadata.
|
|
189
218
|
|
|
190
219
|
Both aliases preserve their routable IDs and use Chat Completions in OpenCode. Their public variants are `off`, `low`, `medium`, and `xhigh` (default `xhigh`): `off` sends `reasoning_effort: "none"`; the other variants send their named effort. Strata accepts `high` and `max` as aliases of native `xhigh`; they are not distinct variants. This differs from the cloud Qwen contract and does not alter it. The `-codex` alias remains available to Codex via LiteLLM's separate Responses adapter. No alias is fabricated when discovery omits it; explicit model/provider overrides still win.
|
|
191
|
-
|
|
192
|
-
## Native image models (OpenCode 2.2+)
|
|
193
|
-
|
|
194
|
-
Current V2 discovery includes LiteLLM `image_generation` deployments as `type: "image"` using the native OpenAI-compatible Images driver. They are available to OpenCode image generation and OpenChamber image model settings, separately from conversation models. Explicit `includeModes` and model filters still apply. Legacy and transitional catalogs omit image deployments. Generation uses `/images/generations`; reference images use `/images/edits`.
|
|
@@ -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/helpers.d.ts
CHANGED
|
File without changes
|
package/dist/helpers.js
CHANGED
|
File without changes
|
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
|
File without changes
|
package/dist/mapping.js
CHANGED
|
@@ -288,11 +288,13 @@ function contractDefaults(name, tombstones = false) {
|
|
|
288
288
|
throw new Error(`Model contract ${entry.family} has no default mode body`);
|
|
289
289
|
const variants = Object.fromEntries(entry.modes.map((mode) => [mode.id, { ...mode.legacy }]));
|
|
290
290
|
if (tombstones) {
|
|
291
|
-
const disabled = entry.family === "DeepSeek V4 Flash"
|
|
292
|
-
?
|
|
293
|
-
: entry.family === "
|
|
294
|
-
? ["
|
|
295
|
-
:
|
|
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
|
+
: [];
|
|
296
298
|
for (const effort of disabled)
|
|
297
299
|
variants[effort] = { disabled: true };
|
|
298
300
|
}
|
|
@@ -486,7 +488,7 @@ export function mapModelsV2(models, options = {}, nativeImages = false) {
|
|
|
486
488
|
const overridesEffort = isRecord(overriddenOptions) && ("reasoningEffort" in overriddenOptions ||
|
|
487
489
|
"reasoning_effort" in overriddenOptions ||
|
|
488
490
|
"chat_template_kwargs" in overriddenOptions);
|
|
489
|
-
// V2 applies the selected variant after request.body, so Qwen off
|
|
491
|
+
// V2 applies the selected variant after request.body, so the Qwen off mode cannot inherit a default effort.
|
|
490
492
|
if (contract && mapped.api?.package !== "@ai-sdk/openai" && !overridesEffort) {
|
|
491
493
|
delete defaultBody.reasoning_effort;
|
|
492
494
|
delete defaultBody.reasoningEffort;
|
package/dist/merge.d.ts
CHANGED
|
File without changes
|
package/dist/merge.js
CHANGED
|
File without changes
|
|
File without changes
|
package/dist/model-contracts.js
CHANGED
|
@@ -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"),
|
|
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
|
@@ -133,6 +133,10 @@ export type LiteLLMPluginOptions = {
|
|
|
133
133
|
/** Exact model_info.backend matches, unioned with includeModels pins. */
|
|
134
134
|
includeBackends?: string[];
|
|
135
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;
|
|
136
140
|
strict?: boolean;
|
|
137
141
|
};
|
|
138
142
|
export type LiteLLMModelInfo = JsonObject & {
|
package/dist/types.js
CHANGED
|
File without changes
|
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,6 +2,7 @@ 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";
|
|
@@ -234,62 +235,85 @@ const LiteLLMPluginV2 = {
|
|
|
234
235
|
id: "opencode-litellm",
|
|
235
236
|
setup: async (ctx) => {
|
|
236
237
|
const options = resolveOptions(ctx.options);
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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;
|
|
241
245
|
try {
|
|
242
|
-
|
|
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
|
+
}
|
|
243
283
|
}
|
|
244
284
|
catch (error) {
|
|
285
|
+
if (controller.signal.aborted)
|
|
286
|
+
return;
|
|
245
287
|
if (options.strict)
|
|
246
288
|
throw error;
|
|
247
|
-
console.warn("opencode-litellm: failed to load LiteLLM
|
|
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;
|
|
248
299
|
}
|
|
249
|
-
|
|
300
|
+
if (controller.signal.aborted)
|
|
301
|
+
return;
|
|
250
302
|
if (ctx.provider?.transform) {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
const id = model.model_name ?? model.litellm_params?.model ?? model.model_info?.id;
|
|
256
|
-
if (!id)
|
|
257
|
-
continue;
|
|
258
|
-
// Opaque Responses aliases need the same transport as GPT-named models.
|
|
259
|
-
overrides[id] = deepMerge({ provider: { npm: "@ai-sdk/openai" } }, overrides[id]);
|
|
260
|
-
}
|
|
261
|
-
provider = buildProviderV2(models, { ...options, overrides }, supportsNativeImages(ctx));
|
|
262
|
-
for (const model of models) {
|
|
263
|
-
if (model.model_info?.use_responses_lite !== true)
|
|
264
|
-
continue;
|
|
265
|
-
const id = model.model_name ?? model.litellm_params?.model ?? model.model_info?.id;
|
|
266
|
-
const mapped = id ? provider.models[id] : undefined;
|
|
267
|
-
if (!mapped || currentModelPackage(mapped, currentProviderPackage(provider)) !== RESPONSES_PACKAGE)
|
|
268
|
-
continue;
|
|
269
|
-
mapped.request ??= {};
|
|
270
|
-
mapped.request.body = deepMerge({ reasoning: { context: "all_turns" } }, mapped.request.body);
|
|
271
|
-
}
|
|
303
|
+
await registerCurrent(ctx, options.providerKey, provider);
|
|
304
|
+
if (recovered)
|
|
305
|
+
await ctx.provider.reload();
|
|
306
|
+
return;
|
|
272
307
|
}
|
|
273
|
-
|
|
274
|
-
|
|
308
|
+
const transitional = ctx;
|
|
309
|
+
if (transitional.catalog?.transform) {
|
|
310
|
+
await transitional.catalog.transform((catalog) => applyTransitional(catalog, options.providerKey, provider));
|
|
311
|
+
return;
|
|
275
312
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
console.warn("opencode-litellm: failed to load LiteLLM provider metadata", error instanceof Error ? error.message : String(error));
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
283
|
-
if (ctx.provider?.transform) {
|
|
284
|
-
await registerCurrent(ctx, options.providerKey, provider);
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
const transitional = ctx;
|
|
288
|
-
if (transitional.catalog?.transform) {
|
|
289
|
-
await transitional.catalog.transform((catalog) => applyTransitional(catalog, options.providerKey, provider));
|
|
290
|
-
return;
|
|
291
|
-
}
|
|
292
|
-
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;
|
|
293
317
|
},
|
|
294
318
|
};
|
|
295
319
|
export default LiteLLMPluginV2;
|