@staticeng/opencode-litellm 0.2.7 → 0.3.1
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 +41 -21
- package/dist/index.d.ts +22 -2
- package/dist/index.js +7 -2
- package/dist/mapping.js +17 -2
- package/dist/model-contracts.js +10 -0
- package/dist/v2.d.ts +7 -0
- package/dist/v2.js +287 -0
- package/package.json +19 -3
package/README.md
CHANGED
|
@@ -4,23 +4,20 @@ Runtime LiteLLM model autodiscovery for OpenCode. The plugin fetches LiteLLM dep
|
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
```sh
|
|
10
|
-
npm install @staticeng/opencode-litellm
|
|
11
|
-
```
|
|
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.
|
|
12
9
|
|
|
13
10
|
```json
|
|
14
11
|
{
|
|
15
12
|
"$schema": "https://opencode.ai/config.json",
|
|
16
|
-
"
|
|
17
|
-
|
|
18
|
-
"@staticeng/opencode-litellm",
|
|
19
|
-
{
|
|
13
|
+
"plugins": [
|
|
14
|
+
{
|
|
15
|
+
"package": "@staticeng/opencode-litellm@0.3.0",
|
|
16
|
+
"options": {
|
|
20
17
|
"baseURL": "https://litellm.example.com/v1",
|
|
21
18
|
"apiKeyEnv": "LITELLM_API_KEY"
|
|
22
19
|
}
|
|
23
|
-
|
|
20
|
+
}
|
|
24
21
|
]
|
|
25
22
|
}
|
|
26
23
|
```
|
|
@@ -35,6 +32,16 @@ Do not put real API keys in `opencode.json`. If you pass `apiKey` directly, the
|
|
|
35
32
|
|
|
36
33
|
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
34
|
|
|
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.
|
|
39
|
+
|
|
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.
|
|
44
|
+
|
|
38
45
|
## Options
|
|
39
46
|
|
|
40
47
|
| Option | Default | Purpose |
|
|
@@ -42,7 +49,7 @@ Shared or synchronized OpenCode configuration should use the published package n
|
|
|
42
49
|
| `baseURL` | `LITELLM_BASE_URL` | LiteLLM proxy base URL. `/v1` URLs are supported. |
|
|
43
50
|
| `apiKey` | env value | Bearer token for LiteLLM. |
|
|
44
51
|
| `apiKeyEnv` | `LITELLM_API_KEY` | Environment variable used when `apiKey` is not set. |
|
|
45
|
-
| `providerKey` | `LiteLLM` |
|
|
52
|
+
| `providerKey` | `LiteLLM` | Provider ID registered in OpenCode. |
|
|
46
53
|
| `providerName` | `LiteLLM` | Human provider name. |
|
|
47
54
|
| `overrides` | `{}` | Per-model deep-merge overrides. |
|
|
48
55
|
| `providerOverrides` | `{}` | Provider-level deep-merge overrides. |
|
|
@@ -65,19 +72,26 @@ When LiteLLM publishes `model_info.supported_reasoning_efforts`, the plugin maps
|
|
|
65
72
|
|
|
66
73
|
```json
|
|
67
74
|
{
|
|
68
|
-
"
|
|
69
|
-
|
|
70
|
-
"@staticeng/opencode-litellm",
|
|
71
|
-
{
|
|
75
|
+
"plugins": [
|
|
76
|
+
{
|
|
77
|
+
"package": "@staticeng/opencode-litellm@0.3.0",
|
|
78
|
+
"options": {
|
|
72
79
|
"baseURL": "https://litellm.example.com/v1",
|
|
73
80
|
"apiKeyEnv": "LITELLM_API_KEY",
|
|
74
81
|
"overrides": {
|
|
75
82
|
"chatgpt/gpt-5.5": {
|
|
76
|
-
"limit": {
|
|
83
|
+
"limit": {
|
|
84
|
+
"context": 262144,
|
|
85
|
+
"output": 128000
|
|
86
|
+
},
|
|
77
87
|
"variants": {
|
|
78
|
-
"high": {
|
|
88
|
+
"high": {
|
|
89
|
+
"reasoningEffort": "high"
|
|
90
|
+
}
|
|
79
91
|
},
|
|
80
|
-
"interleaved": {
|
|
92
|
+
"interleaved": {
|
|
93
|
+
"field": "reasoning_details"
|
|
94
|
+
}
|
|
81
95
|
}
|
|
82
96
|
},
|
|
83
97
|
"providerOverrides": {
|
|
@@ -86,7 +100,7 @@ When LiteLLM publishes `model_info.supported_reasoning_efforts`, the plugin maps
|
|
|
86
100
|
}
|
|
87
101
|
}
|
|
88
102
|
}
|
|
89
|
-
|
|
103
|
+
}
|
|
90
104
|
]
|
|
91
105
|
}
|
|
92
106
|
```
|
|
@@ -139,7 +153,7 @@ Normal GPT-5.3 Codex and GPT-5.3 Codex Spark aliases are retired and filtered ev
|
|
|
139
153
|
|
|
140
154
|
- Fetches `${baseURL}/model/info`, then root `/model_group/info`, with separate authenticated and timed requests.
|
|
141
155
|
- Preserves `/v1` for model-info discovery but resolves model-group discovery from the LiteLLM origin.
|
|
142
|
-
-
|
|
156
|
+
- Registers the provider and models with the native V2 provider transform API; V1 uses the config hook.
|
|
143
157
|
- Keeps namespaced deployment IDs and augments them with exact bare model-group IDs; exact collisions are enriched deterministically.
|
|
144
158
|
- Skips malformed group rows without a nonblank `model_group` and exposes reasoning capability on legacy and V2 model metadata.
|
|
145
159
|
- Applies exact built-in model contracts as defaults, then applies explicit model and provider overrides last.
|
|
@@ -164,7 +178,13 @@ npm pack --dry-run
|
|
|
164
178
|
|
|
165
179
|
Confirm package contents before publishing and keep secrets out of fixtures, docs, logs, and config examples.
|
|
166
180
|
|
|
167
|
-
|
|
181
|
+
The GitHub release workflow supports npm trusted publishing from the `npm-production` environment. Provenance
|
|
168
182
|
requires this GitHub repository to remain public. Before a release, configure npm's trusted
|
|
169
183
|
publisher for package `@staticeng/opencode-litellm` with this repository, workflow
|
|
170
184
|
`publish.yml`, and environment `npm-production`; no npm token is used by the workflow.
|
|
185
|
+
|
|
186
|
+
### Local RTX 5090 Strata aliases
|
|
187
|
+
|
|
188
|
+
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
|
+
|
|
190
|
+
Both aliases preserve their routable IDs and use Chat Completions in OpenCode. Their public variants are `off`, `low`, `medium`, and `high` (default `high`): `off` sends `reasoning_effort: "none"`; the other variants send their named effort. Strata translates public `high` to its native engine effort. 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.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Config, PluginInput } from "@opencode-ai/plugin";
|
|
2
2
|
import type { LiteLLMPluginOptions } from "./types.js";
|
|
3
|
-
declare const LiteLLMPlugin: (ctx: PluginInput, options?: LiteLLMPluginOptions) => Promise<{
|
|
3
|
+
export declare const LiteLLMPlugin: (ctx: PluginInput, options?: LiteLLMPluginOptions) => Promise<{
|
|
4
4
|
config: (cfg: Config) => Promise<void>;
|
|
5
5
|
"chat.params": (input: {
|
|
6
6
|
sessionID: string;
|
|
@@ -16,4 +16,24 @@ declare const LiteLLMPlugin: (ctx: PluginInput, options?: LiteLLMPluginOptions)
|
|
|
16
16
|
options: Record<string, any>;
|
|
17
17
|
}) => Promise<void>;
|
|
18
18
|
}>;
|
|
19
|
-
|
|
19
|
+
declare const _default: {
|
|
20
|
+
id: string;
|
|
21
|
+
server: (ctx: PluginInput, options?: LiteLLMPluginOptions) => Promise<{
|
|
22
|
+
config: (cfg: Config) => Promise<void>;
|
|
23
|
+
"chat.params": (input: {
|
|
24
|
+
sessionID: string;
|
|
25
|
+
agent: string;
|
|
26
|
+
model: import("@opencode-ai/sdk").Model;
|
|
27
|
+
provider: import("@opencode-ai/plugin").ProviderContext;
|
|
28
|
+
message: import("@opencode-ai/sdk").UserMessage;
|
|
29
|
+
}, output: {
|
|
30
|
+
temperature: number;
|
|
31
|
+
topP: number;
|
|
32
|
+
topK: number;
|
|
33
|
+
maxOutputTokens: number | undefined;
|
|
34
|
+
options: Record<string, any>;
|
|
35
|
+
}) => Promise<void>;
|
|
36
|
+
}>;
|
|
37
|
+
setup: (ctx: import("@opencode/plugin/promise/plugin").Context) => Promise<void>;
|
|
38
|
+
};
|
|
39
|
+
export default _default;
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
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
|
+
import V2Plugin from "./v2.js";
|
|
4
5
|
async function log(ctx, level, message, extra = {}) {
|
|
5
6
|
await ctx.client?.app?.log?.({ body: { service: "opencode-litellm", level, message, extra } });
|
|
6
7
|
}
|
|
7
|
-
const LiteLLMPlugin = async (ctx, options) => {
|
|
8
|
+
export const LiteLLMPlugin = async (ctx, options) => {
|
|
8
9
|
const resolved = resolveOptions(options);
|
|
9
10
|
return {
|
|
10
11
|
config: async (cfg) => {
|
|
@@ -68,4 +69,8 @@ const LiteLLMPlugin = async (ctx, options) => {
|
|
|
68
69
|
}),
|
|
69
70
|
};
|
|
70
71
|
};
|
|
71
|
-
export default
|
|
72
|
+
export default {
|
|
73
|
+
id: V2Plugin.id,
|
|
74
|
+
server: LiteLLMPlugin,
|
|
75
|
+
setup: V2Plugin.setup,
|
|
76
|
+
};
|
package/dist/mapping.js
CHANGED
|
@@ -464,9 +464,24 @@ export function mapModelsV2(models, options = {}) {
|
|
|
464
464
|
const mapped = v2Model(model, options.baseURL);
|
|
465
465
|
const variants = contractV2Variants(name, model, options.overrides?.[name]);
|
|
466
466
|
const result = variants ? { ...mapped, variants } : mapped;
|
|
467
|
+
const contract = modelContract(name);
|
|
468
|
+
const defaultBody = isRecord(model.options) ? v2VariantBody(model.options) : {};
|
|
469
|
+
delete defaultBody.variantDefault;
|
|
470
|
+
const overriddenOptions = options.overrides?.[name]?.options;
|
|
471
|
+
const overridesEffort = isRecord(overriddenOptions) && ("reasoningEffort" in overriddenOptions ||
|
|
472
|
+
"reasoning_effort" in overriddenOptions ||
|
|
473
|
+
"chat_template_kwargs" in overriddenOptions);
|
|
474
|
+
// V2 applies the selected variant after request.body, so Qwen off must not inherit its default effort.
|
|
475
|
+
if (contract && mapped.api?.package !== "@ai-sdk/openai" && !overridesEffort) {
|
|
476
|
+
delete defaultBody.reasoning_effort;
|
|
477
|
+
delete defaultBody.reasoningEffort;
|
|
478
|
+
}
|
|
479
|
+
result.request = {
|
|
480
|
+
body: defaultBody,
|
|
481
|
+
...(contract && !overridesEffort ? { variant: contract.defaultMode } : {}),
|
|
482
|
+
};
|
|
467
483
|
if (mapped.api?.package === "@ai-sdk/openai") {
|
|
468
|
-
|
|
469
|
-
result.request = { body };
|
|
484
|
+
result.request.body = responsesBody(result.request.body ?? {});
|
|
470
485
|
result.variants = result.variants?.map((variant) => ({ ...variant, body: responsesBody(variant.body ?? {}) }));
|
|
471
486
|
}
|
|
472
487
|
return [name, result];
|
package/dist/model-contracts.js
CHANGED
|
@@ -54,6 +54,15 @@ const QWEN_FLASH_NEXT_METADATA = Object.freeze({
|
|
|
54
54
|
tool_call: true,
|
|
55
55
|
limit: Object.freeze({ context: 262144, output: 0 }),
|
|
56
56
|
});
|
|
57
|
+
// Strata exposes high publicly and translates it to the engine's native effort.
|
|
58
|
+
const STRATA_QWEN_MODES = Object.freeze([
|
|
59
|
+
Object.freeze({ id: "off", legacy: freezeJson({ reasoningEffort: "none" }), v2Body: freezeJson({ reasoning_effort: "none" }) }),
|
|
60
|
+
...effortModes(["low", "medium", "high"]),
|
|
61
|
+
]);
|
|
62
|
+
const STRATA_QWEN_METADATA = Object.freeze({
|
|
63
|
+
...QWEN_FLASH_NEXT_METADATA,
|
|
64
|
+
limit: Object.freeze({ context: 262144, output: 262144 }),
|
|
65
|
+
});
|
|
57
66
|
const chatGptAliases = (model) => Object.freeze([
|
|
58
67
|
model,
|
|
59
68
|
`chatgpt/${model}`,
|
|
@@ -73,6 +82,7 @@ export const MODEL_CONTRACTS = Object.freeze([
|
|
|
73
82
|
contract("DeepSeek V4 Flash", ["deepseek-v4-flash-fp8-mtp", "deepseek-v4-flash-fp8-mtp-norefusal"], DEEPSEEK_MODES, "max"),
|
|
74
83
|
contract("Qwen3.8", ["qwen3.8-27b-refusal-dial"], QWEN_MODES, "xhigh"),
|
|
75
84
|
contract("Qwen3.8", ["qwen3.8-flash-next"], QWEN_MODES, "xhigh", QWEN_FLASH_NEXT_METADATA, true),
|
|
85
|
+
contract("Qwen3.8 Strata", ["5090-local-model", "5090-local-model-codex"], STRATA_QWEN_MODES, "high", STRATA_QWEN_METADATA, true),
|
|
76
86
|
]);
|
|
77
87
|
export const RETIRED_MODEL_ALIASES = Object.freeze([
|
|
78
88
|
...chatGptAliases("gpt-5.3-codex"),
|
package/dist/v2.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Plugin as CurrentPlugin } from "@opencode/plugin";
|
|
2
|
+
type CurrentContext = Parameters<CurrentPlugin.Plugin["setup"]>[0];
|
|
3
|
+
declare const LiteLLMPluginV2: {
|
|
4
|
+
id: string;
|
|
5
|
+
setup: (ctx: CurrentContext) => Promise<void>;
|
|
6
|
+
};
|
|
7
|
+
export default LiteLLMPluginV2;
|
package/dist/v2.js
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { fetchModelGroupInfo, fetchModelInfo } from "./litellm.js";
|
|
2
|
+
import { buildProviderV2, mergeModelInfo } from "./mapping.js";
|
|
3
|
+
import { deepMerge } from "./merge.js";
|
|
4
|
+
import { resolveOptions } from "./options.js";
|
|
5
|
+
const CHAT_PACKAGE = "@opencode/ai/providers/openai-compatible";
|
|
6
|
+
// The 2.0.16 binary bundles this driver, but not openai-compatible/responses.
|
|
7
|
+
const RESPONSES_PACKAGE = "@opencode/ai/providers/openai/responses";
|
|
8
|
+
function packageName(name) {
|
|
9
|
+
if (name === "@ai-sdk/openai" || name === "@opencode/ai/providers/openai-compatible/responses")
|
|
10
|
+
return RESPONSES_PACKAGE;
|
|
11
|
+
if (name === "@ai-sdk/openai-compatible")
|
|
12
|
+
return CHAT_PACKAGE;
|
|
13
|
+
return name;
|
|
14
|
+
}
|
|
15
|
+
function isRecord(value) {
|
|
16
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
function costTiers(cost) {
|
|
19
|
+
if (cost === undefined)
|
|
20
|
+
return [];
|
|
21
|
+
return (Array.isArray(cost) ? cost : [cost]).map((tier) => ({
|
|
22
|
+
...(tier.tier ? { tier: { ...tier.tier } } : {}),
|
|
23
|
+
input: tier.input,
|
|
24
|
+
output: tier.output,
|
|
25
|
+
cache: { read: tier.cache?.read ?? 0, write: tier.cache?.write ?? 0 },
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
function currentProviderPackage(config) {
|
|
29
|
+
return packageName(typeof config.npm === "string" ? config.npm : config.api.package);
|
|
30
|
+
}
|
|
31
|
+
function currentModelPackage(config, providerPackage) {
|
|
32
|
+
return packageName(config.api?.package === "@ai-sdk/openai-compatible" ? providerPackage : config.api?.package ?? providerPackage);
|
|
33
|
+
}
|
|
34
|
+
function currentModel(providerID, id, config, providerPackage) {
|
|
35
|
+
const settings = {
|
|
36
|
+
...config.api?.settings,
|
|
37
|
+
...(config.api?.url ? { baseURL: config.api.url } : {}),
|
|
38
|
+
};
|
|
39
|
+
const body = { ...config.request?.body };
|
|
40
|
+
// Current V2 has no default-variant field; a session hook supplies that effort for default requests.
|
|
41
|
+
delete body.reasoning_effort;
|
|
42
|
+
delete body.reasoningEffort;
|
|
43
|
+
if (isRecord(body.reasoning)) {
|
|
44
|
+
const { effort: _effort, ...reasoning } = body.reasoning;
|
|
45
|
+
if (Object.keys(reasoning).length)
|
|
46
|
+
body.reasoning = reasoning;
|
|
47
|
+
else
|
|
48
|
+
delete body.reasoning;
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
id,
|
|
52
|
+
modelID: config.api?.id ?? id,
|
|
53
|
+
providerID,
|
|
54
|
+
name: config.name ?? id,
|
|
55
|
+
...(config.family ? { family: config.family } : {}),
|
|
56
|
+
package: currentModelPackage(config, providerPackage),
|
|
57
|
+
...(Object.keys(settings).length ? { settings } : {}),
|
|
58
|
+
...(config.request?.headers ? { headers: { ...config.request.headers } } : {}),
|
|
59
|
+
...(Object.keys(body).length ? { body } : {}),
|
|
60
|
+
capabilities: {
|
|
61
|
+
tools: config.capabilities?.tools ?? false,
|
|
62
|
+
input: [...(config.capabilities?.input ?? ["text"])],
|
|
63
|
+
output: [...(config.capabilities?.output ?? ["text"])],
|
|
64
|
+
},
|
|
65
|
+
variants: (config.variants ?? []).map((variant) => ({
|
|
66
|
+
id: variant.id,
|
|
67
|
+
...(variant.headers ? { headers: { ...variant.headers } } : {}),
|
|
68
|
+
...(variant.body ? { body: { ...variant.body } } : {}),
|
|
69
|
+
})),
|
|
70
|
+
time: { released: 0 },
|
|
71
|
+
cost: costTiers(config.cost),
|
|
72
|
+
status: "active",
|
|
73
|
+
enabled: !config.disabled,
|
|
74
|
+
limit: {
|
|
75
|
+
context: config.limit?.context ?? 0,
|
|
76
|
+
...(config.limit?.input === undefined ? {} : { input: config.limit.input }),
|
|
77
|
+
output: config.limit?.output ?? 0,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function defaultVariants(provider) {
|
|
82
|
+
return new Map(Object.entries(provider.models).flatMap(([id, model]) => {
|
|
83
|
+
const selected = model.variants?.find((variant) => variant.id === model.request?.variant);
|
|
84
|
+
return selected ? [[id, selected]] : [];
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
function defaultEfforts(provider) {
|
|
88
|
+
const variants = defaultVariants(provider);
|
|
89
|
+
return new Map(Object.entries(provider.models).flatMap(([id, model]) => {
|
|
90
|
+
const selected = variants.get(id);
|
|
91
|
+
const body = selected?.body ?? model.request?.body;
|
|
92
|
+
const reasoning = body?.reasoning;
|
|
93
|
+
const effort = typeof reasoning === "object" && reasoning !== null && !Array.isArray(reasoning)
|
|
94
|
+
? reasoning.effort
|
|
95
|
+
: body?.reasoning_effort ?? body?.reasoningEffort;
|
|
96
|
+
return typeof effort === "string" ? [[id, effort]] : [];
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
async function registerCurrent(ctx, providerID, config) {
|
|
100
|
+
const providerOptions = isRecord(config.options) ? config.options : {};
|
|
101
|
+
const { body: optionBody, ...optionSettings } = providerOptions;
|
|
102
|
+
const providerPackage = currentProviderPackage(config);
|
|
103
|
+
const mergedBody = deepMerge(config.request.body, isRecord(optionBody) ? optionBody : undefined);
|
|
104
|
+
const { apiKey, ...body } = mergedBody;
|
|
105
|
+
const settings = {
|
|
106
|
+
...(typeof config.request.body.apiKey === "string" ? { apiKey: config.request.body.apiKey } : {}),
|
|
107
|
+
...config.api.settings,
|
|
108
|
+
...(config.api.url ? { baseURL: config.api.url } : {}),
|
|
109
|
+
...(isRecord(optionBody) && typeof optionBody.apiKey === "string" ? { apiKey: optionBody.apiKey } : {}),
|
|
110
|
+
...optionSettings,
|
|
111
|
+
};
|
|
112
|
+
const models = Object.entries(config.models).map(([id, model]) => currentModel(providerID, id, model, providerPackage));
|
|
113
|
+
await ctx.provider.transform((editor) => {
|
|
114
|
+
editor.update(providerID, (provider) => {
|
|
115
|
+
provider.name = config.name ?? providerID;
|
|
116
|
+
provider.activation = config.disabled ? "disabled" : "enabled";
|
|
117
|
+
provider.package = providerPackage;
|
|
118
|
+
provider.settings = { ...settings };
|
|
119
|
+
provider.headers = { ...config.request.headers };
|
|
120
|
+
provider.body = { ...body };
|
|
121
|
+
if (typeof config.integrationID === "string")
|
|
122
|
+
provider.integrationID = config.integrationID;
|
|
123
|
+
});
|
|
124
|
+
editor.models.set(providerID, models);
|
|
125
|
+
});
|
|
126
|
+
const defaults = defaultEfforts(config);
|
|
127
|
+
if (defaults.size) {
|
|
128
|
+
for (const hook of ["context", "compaction", "generate", "title"]) {
|
|
129
|
+
await ctx.session.hook(hook, (event) => {
|
|
130
|
+
if (event.model.providerID !== providerID || (event.model.variant && event.model.variant !== "default"))
|
|
131
|
+
return;
|
|
132
|
+
const effort = defaults.get(event.model.id);
|
|
133
|
+
if (effort && event.options.reasoningEffort === undefined)
|
|
134
|
+
event.options.reasoningEffort = effort;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const variants = defaultVariants(config);
|
|
139
|
+
if (variants.size) {
|
|
140
|
+
await ctx.session.hook("http.request", async (event) => {
|
|
141
|
+
if (event.model.providerID !== providerID || (event.model.variant && event.model.variant !== "default"))
|
|
142
|
+
return;
|
|
143
|
+
const selected = variants.get(event.model.id);
|
|
144
|
+
if (!selected)
|
|
145
|
+
return;
|
|
146
|
+
const headers = new Headers(event.request.headers);
|
|
147
|
+
for (const [name, value] of Object.entries(selected.headers ?? {}))
|
|
148
|
+
headers.set(name, value);
|
|
149
|
+
if (!selected.body || !Object.keys(selected.body).length) {
|
|
150
|
+
event.request = new Request(event.request, { headers });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
let wireBody;
|
|
154
|
+
try {
|
|
155
|
+
wireBody = JSON.parse(await event.request.clone().text());
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (!isRecord(wireBody))
|
|
161
|
+
return;
|
|
162
|
+
const merged = deepMerge(wireBody, selected.body);
|
|
163
|
+
// A request-level effort already serialized by the driver takes precedence.
|
|
164
|
+
for (const key of ["reasoning_effort", "reasoningEffort"]) {
|
|
165
|
+
if (key in wireBody)
|
|
166
|
+
merged[key] = wireBody[key];
|
|
167
|
+
}
|
|
168
|
+
if (isRecord(wireBody.reasoning) && "effort" in wireBody.reasoning && isRecord(merged.reasoning)) {
|
|
169
|
+
merged.reasoning.effort = wireBody.reasoning.effort;
|
|
170
|
+
}
|
|
171
|
+
headers.delete("content-length");
|
|
172
|
+
event.request = new Request(event.request, { headers, body: JSON.stringify(merged) });
|
|
173
|
+
}, { providerID });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function applyTransitionalModel(catalog, providerID, id, config) {
|
|
177
|
+
catalog.model.update(providerID, id, (model) => {
|
|
178
|
+
if (config.family !== undefined)
|
|
179
|
+
model.family = config.family;
|
|
180
|
+
if (config.name !== undefined)
|
|
181
|
+
model.name = config.name;
|
|
182
|
+
if (config.api !== undefined)
|
|
183
|
+
model.api = {
|
|
184
|
+
...model.api, ...config.api, id: config.api.id ?? id, type: "aisdk", package: config.api.package,
|
|
185
|
+
};
|
|
186
|
+
if (config.capabilities !== undefined)
|
|
187
|
+
model.capabilities = {
|
|
188
|
+
tools: config.capabilities.tools,
|
|
189
|
+
input: [...config.capabilities.input],
|
|
190
|
+
output: [...config.capabilities.output],
|
|
191
|
+
};
|
|
192
|
+
if (config.request !== undefined) {
|
|
193
|
+
Object.assign(model.request.headers, config.request.headers);
|
|
194
|
+
Object.assign(model.request.body, config.request.body);
|
|
195
|
+
if (config.request.variant !== undefined)
|
|
196
|
+
model.request.variant = config.request.variant;
|
|
197
|
+
}
|
|
198
|
+
if (config.variants !== undefined)
|
|
199
|
+
model.variants = config.variants.map((variant) => ({
|
|
200
|
+
id: variant.id, headers: { ...variant.headers }, body: { ...variant.body },
|
|
201
|
+
}));
|
|
202
|
+
if (config.cost !== undefined)
|
|
203
|
+
model.cost = costTiers(config.cost);
|
|
204
|
+
if (config.disabled !== undefined)
|
|
205
|
+
model.enabled = !config.disabled;
|
|
206
|
+
if (config.limit !== undefined)
|
|
207
|
+
model.limit = { ...model.limit, ...config.limit };
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
function applyTransitional(catalog, providerID, config) {
|
|
211
|
+
catalog.provider.update(providerID, (provider) => {
|
|
212
|
+
if (config.name !== undefined)
|
|
213
|
+
provider.name = config.name;
|
|
214
|
+
provider.api = { ...config.api };
|
|
215
|
+
Object.assign(provider.request.headers, config.request.headers);
|
|
216
|
+
Object.assign(provider.request.body, config.request.body);
|
|
217
|
+
if (typeof config.disabled === "boolean")
|
|
218
|
+
provider.disabled = config.disabled;
|
|
219
|
+
if (typeof config.integrationID === "string")
|
|
220
|
+
provider.integrationID = config.integrationID;
|
|
221
|
+
});
|
|
222
|
+
for (const [id, model] of Object.entries(config.models))
|
|
223
|
+
applyTransitionalModel(catalog, providerID, id, model);
|
|
224
|
+
}
|
|
225
|
+
const LiteLLMPluginV2 = {
|
|
226
|
+
id: "opencode-litellm",
|
|
227
|
+
setup: async (ctx) => {
|
|
228
|
+
const options = resolveOptions(ctx.options);
|
|
229
|
+
let provider;
|
|
230
|
+
try {
|
|
231
|
+
const deployments = await fetchModelInfo(options);
|
|
232
|
+
let groups = [];
|
|
233
|
+
try {
|
|
234
|
+
groups = await fetchModelGroupInfo(options);
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
if (options.strict)
|
|
238
|
+
throw error;
|
|
239
|
+
console.warn("opencode-litellm: failed to load LiteLLM model group metadata", error instanceof Error ? error.message : String(error));
|
|
240
|
+
}
|
|
241
|
+
const models = mergeModelInfo(deployments, groups);
|
|
242
|
+
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
|
+
}
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
provider = buildProviderV2(models, options);
|
|
267
|
+
}
|
|
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");
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
export default LiteLLMPluginV2;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@staticeng/opencode-litellm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "OpenCode plugin that discovers LiteLLM models at runtime.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
"./helpers": {
|
|
15
15
|
"types": "./dist/helpers.d.ts",
|
|
16
16
|
"import": "./dist/helpers.js"
|
|
17
|
+
},
|
|
18
|
+
"./v2": {
|
|
19
|
+
"types": "./dist/v2.d.ts",
|
|
20
|
+
"import": "./dist/v2.js"
|
|
17
21
|
}
|
|
18
22
|
},
|
|
19
23
|
"files": [
|
|
@@ -27,13 +31,25 @@
|
|
|
27
31
|
"prepack": "npm run build"
|
|
28
32
|
},
|
|
29
33
|
"peerDependencies": {
|
|
30
|
-
"@opencode-ai/plugin": "
|
|
34
|
+
"@opencode-ai/plugin": ">=1.18.29",
|
|
35
|
+
"@opencode/plugin": ">=2.0.16"
|
|
36
|
+
},
|
|
37
|
+
"peerDependenciesMeta": {
|
|
38
|
+
"@opencode-ai/plugin": {
|
|
39
|
+
"optional": true
|
|
40
|
+
},
|
|
41
|
+
"@opencode/plugin": {
|
|
42
|
+
"optional": true
|
|
43
|
+
}
|
|
31
44
|
},
|
|
32
45
|
"devDependencies": {
|
|
33
46
|
"@ai-sdk/openai": "3.0.84",
|
|
34
47
|
"@ai-sdk/openai-compatible": "2.0.41",
|
|
35
|
-
"@opencode-ai/plugin": "
|
|
48
|
+
"@opencode-ai/plugin": "1.18.32",
|
|
49
|
+
"@opencode/ai": "2.0.16",
|
|
50
|
+
"@opencode/plugin": "2.0.16",
|
|
36
51
|
"@types/node": "^24.10.1",
|
|
52
|
+
"effect": "4.0.0-rc.112",
|
|
37
53
|
"typescript": "^5.9.3"
|
|
38
54
|
},
|
|
39
55
|
"engines": {
|