@xberg-io/opencode-liter-llm 2.1.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,43 @@
1
+ {
2
+ "mcp": {
3
+ "liter-llm": {
4
+ "type": "local",
5
+ "command": [
6
+ "${PLUGIN_ROOT}/scripts/mcp-launch.sh",
7
+ "mcp",
8
+ "--transport",
9
+ "stdio"
10
+ ]
11
+ }
12
+ },
13
+ "skills": {
14
+ "calling-llms": {
15
+ "name": "calling-llms",
16
+ "description": "Use when sending chat completions through liter-llm and routing to a specific provider via the `provider/model` prefix. Covers the chat call shape, provider routing, model_hint, message roles, and error categories."
17
+ },
18
+ "embeddings-and-search": {
19
+ "name": "embeddings-and-search",
20
+ "description": "Use when generating embeddings, calling the 12 web-search providers, or running OCR over documents with the 4 OCR providers through liter-llm. Covers embed, search, and ocr methods plus reranking."
21
+ },
22
+ "liter-llm": {
23
+ "name": "liter-llm",
24
+ "description": "Universal LLM API client for 174 providers with native bindings for 14 languages. Use when writing code that calls LLM APIs via liter-llm in Python, TypeScript, Rust, Go, Java, C#, Ruby, PHP, Elixir, WASM, or C, when running the OpenAI-compatible proxy, or when calling LLMs through the MCP server. Covers chat, streaming, tool calling, embeddings, image generation, speech, transcription, moderation, web search, OCR, reranking, provider routing, middleware, and configuration."
25
+ },
26
+ "running-the-proxy": {
27
+ "name": "running-the-proxy",
28
+ "description": "Use when running the `liter-llm api` OpenAI-compatible gateway — virtual keys, per-key rate limits, budgets, cost tracking, and model routing. Covers the TOML config and the 22 REST endpoints."
29
+ },
30
+ "streaming-responses": {
31
+ "name": "streaming-responses",
32
+ "description": "Use when streaming tokens incrementally from an LLM via liter-llm over SSE or async iterators. Covers chat_stream, delta handling, and null-content chunks."
33
+ },
34
+ "tool-calling": {
35
+ "name": "tool-calling",
36
+ "description": "Use when defining functions/tools for an LLM to call through liter-llm, or requesting structured JSON outputs. Covers tool schemas, tool_calls handling, and response formats."
37
+ },
38
+ "using-the-mcp-server": {
39
+ "name": "using-the-mcp-server",
40
+ "description": "Use when calling LLM APIs through the liter-llm MCP server's 22 tools, and to decide when MCP beats the CLI or SDK. Covers the tool surface, the auto-installing launcher, and authentication."
41
+ }
42
+ }
43
+ }
@@ -0,0 +1,167 @@
1
+ // AI-RULEZ :: GENERATED FILE — DO NOT EDIT
2
+ // Content-Hash: blake3:822c510deb007e4f72877ad42f29918d0499f210ae363e82d465a0f0331ab294
3
+ // Source-Hash: blake3:8e912c45817ab091b08e38cc7bf8e0a4d28b63546499306a907ade79e33dbe17
4
+ // Schema-Version: v1
5
+
6
+ // Generated by ai-rulez. Registers the skills, commands, agents, and MCP servers bundled with
7
+ // this plugin in OpenCode v2. OpenCode only scans its own config directories, so a plugin installed from
8
+ // npm has to register its bundled content itself.
9
+ import { readdir, readFile } from "node:fs/promises";
10
+ import path from "node:path";
11
+
12
+ const root = import.meta.dirname;
13
+
14
+ // Returns the text after a leading YAML frontmatter block. The frontmatter fields the
15
+ // helper needs (name, description) are resolved when the bundle is generated and read
16
+ // from ai-rulez-bundle.json, so no YAML is parsed here.
17
+ function body(text) {
18
+ const match = /^---\r?\n[\s\S]*?\r?\n---\r?\n?([\s\S]*)$/u.exec(text);
19
+ return match ? match[1] : text;
20
+ }
21
+
22
+ function skip(kind, name, error) {
23
+ console.warn(`ai-rulez: skipping bundled ${kind} ${name}: ${error?.message ?? error}`);
24
+ }
25
+
26
+ async function entries(dir) {
27
+ try {
28
+ return (await readdir(path.join(root, dir), { withFileTypes: true })).toSorted((a, b) =>
29
+ a.name.localeCompare(b.name),
30
+ );
31
+ } catch {
32
+ return [];
33
+ }
34
+ }
35
+
36
+ async function readBundle() {
37
+ try {
38
+ return JSON.parse(await readFile(path.join(root, "ai-rulez-bundle.json"), "utf8"));
39
+ } catch {
40
+ return {};
41
+ }
42
+ }
43
+
44
+ // Plugin install directory: the parent of the .opencode directory this file lives in.
45
+ const pluginRoot = path.dirname(root);
46
+
47
+ // Resolves ${PLUGIN_ROOT} to the install directory and ${NAME} to process.env at runtime,
48
+ // so no install path or secret is baked into the package.
49
+ function expand(value) {
50
+ return value.replaceAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/gu, (_, name) =>
51
+ name === "PLUGIN_ROOT" ? pluginRoot : (process.env[name] ?? ""),
52
+ );
53
+ }
54
+
55
+ function mcpServer(server) {
56
+ if (server.type === "remote") {
57
+ const entry = { type: "remote", url: expand(server.url) };
58
+ if (server.disabled) entry.disabled = true;
59
+ return entry;
60
+ }
61
+ const entry = { type: "local", command: server.command.map(expand) };
62
+ if (server.disabled) entry.disabled = true;
63
+ if (server.environment) {
64
+ entry.environment = Object.fromEntries(
65
+ Object.entries(server.environment).map(([key, value]) => [key, expand(value)]),
66
+ );
67
+ }
68
+ return entry;
69
+ }
70
+
71
+ async function load() {
72
+ const bundle = await readBundle();
73
+ const skills = [];
74
+ for (const entry of await entries("skills")) {
75
+ if (!entry.isDirectory()) continue;
76
+ const file = path.join(root, "skills", entry.name, "SKILL.md");
77
+ try {
78
+ const info = bundle.skills?.[entry.name] ?? {};
79
+ skills.push({
80
+ id: entry.name,
81
+ name: info.name || entry.name,
82
+ description: info.description,
83
+ path: file,
84
+ content: body(await readFile(file, "utf8")),
85
+ });
86
+ } catch (error) {
87
+ skip("skill", entry.name, error);
88
+ }
89
+ }
90
+ const commands = [];
91
+ for (const entry of await entries("commands")) {
92
+ if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
93
+ const name = entry.name.slice(0, -3);
94
+ try {
95
+ const template = body(await readFile(path.join(root, "commands", entry.name), "utf8"));
96
+ commands.push({ name, description: bundle.commands?.[name]?.description, template });
97
+ } catch (error) {
98
+ skip("command", name, error);
99
+ }
100
+ }
101
+ const agents = [];
102
+ for (const entry of await entries("agents")) {
103
+ if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
104
+ const id = entry.name.slice(0, -3);
105
+ try {
106
+ // The bundle holds settings resolved at generation time (the model in particular).
107
+ const system = body(await readFile(path.join(root, "agents", entry.name), "utf8"));
108
+ agents.push({ id, system: system.trim(), settings: bundle.agents?.[id] ?? {} });
109
+ } catch (error) {
110
+ skip("agent", id, error);
111
+ }
112
+ }
113
+ return { skills, commands, agents, mcp: bundle.mcp ?? {} };
114
+ }
115
+
116
+ export async function registerBundledContent(ctx) {
117
+ const { skills, commands, agents, mcp } = await load();
118
+ if (skills.length > 0) {
119
+ await ctx.skill.transform((editor) => {
120
+ for (const skill of skills) if (!editor.get(skill.id)) editor.add(skill);
121
+ });
122
+ }
123
+ if (commands.length > 0) {
124
+ await ctx.command.transform((editor) => {
125
+ for (const command of commands) {
126
+ editor.add({
127
+ name: command.name,
128
+ description: command.description,
129
+ execute: async ({ sessionID, prompt, delivery }) => {
130
+ await ctx.session.prompt({
131
+ ...prompt,
132
+ sessionID,
133
+ text: command.template.replaceAll("$ARGUMENTS", () => String(prompt.text ?? "")),
134
+ delivery,
135
+ });
136
+ },
137
+ });
138
+ }
139
+ });
140
+ }
141
+ if (agents.length > 0) {
142
+ await ctx.agent.transform((editor) => {
143
+ for (const agent of agents) {
144
+ editor.update(agent.id, (item) => {
145
+ const settings = agent.settings;
146
+ if (settings.description) item.description = settings.description;
147
+ if (["subagent", "primary", "all"].includes(settings.mode)) item.mode = settings.mode;
148
+ if (typeof settings.hidden === "boolean") item.hidden = settings.hidden;
149
+ if (typeof settings.model === "string" && settings.model.includes("/")) {
150
+ const split = settings.model.indexOf("/");
151
+ item.model = { providerID: settings.model.slice(0, split), id: settings.model.slice(split + 1) };
152
+ if (settings.variant) item.model.variant = settings.variant;
153
+ }
154
+ for (const key of ["temperature", "top_p"]) {
155
+ if (typeof settings[key] === "number") item.request.body[key] = settings[key];
156
+ }
157
+ if (agent.system) item.system = agent.system;
158
+ });
159
+ }
160
+ });
161
+ }
162
+ if (Object.keys(mcp).length > 0) {
163
+ await ctx.mcp.transform((editor) => {
164
+ for (const [name, server] of Object.entries(mcp)) editor.set(name, mcpServer(server));
165
+ });
166
+ }
167
+ }
@@ -1,6 +1,6 @@
1
1
  // AI-RULEZ :: GENERATED FILE — DO NOT EDIT
2
2
  // Content-Hash: blake3:1ad3d813d05d2ed27d81f6e6848ad8cf124882181d5f4d5ca2e62e81d7bbc91c
3
- // Source-Hash: blake3:651c31e789b5b3a98bbebecbda0ea4f0b6222b63c8264db7c677af355c5fe27b
3
+ // Source-Hash: blake3:8e912c45817ab091b08e38cc7bf8e0a4d28b63546499306a907ade79e33dbe17
4
4
  // Schema-Version: v1
5
5
 
6
6
  /**
@@ -0,0 +1,69 @@
1
+ ---
2
+ name: calling-llms
3
+ description: Use when sending chat completions through liter-llm and routing to a specific provider via the `provider/model` prefix. Covers the chat call shape, provider routing, model_hint, message roles, and error categories.
4
+ ---
5
+
6
+ <!--
7
+ AI-RULEZ :: GENERATED FILE — DO NOT EDIT
8
+ Content-Hash: blake3:116e9240923b4f529f06471698b0e15ce1a14688a5beb1cae28e0b62e3a2837f
9
+ Source-Hash: blake3:8e912c45817ab091b08e38cc7bf8e0a4d28b63546499306a907ade79e33dbe17
10
+ Schema-Version: v1
11
+ -->
12
+
13
+ # Calling LLMs
14
+
15
+ Build a `ChatCompletionRequest` and send it with `client.chat(request)`. Create
16
+ the client with `create_client(...)`. The model string is `provider/model`; the
17
+ prefix selects the backend.
18
+
19
+ ```python
20
+ import asyncio, json, os
21
+ from liter_llm import create_client
22
+ from liter_llm._internal_bindings import ChatCompletionRequest
23
+
24
+ async def main() -> None:
25
+ client = create_client(api_key=os.environ["OPENAI_API_KEY"])
26
+ request = ChatCompletionRequest.from_json(json.dumps({
27
+ "model": "openai/gpt-4o",
28
+ "messages": [
29
+ {"role": "system", "content": "You are concise."},
30
+ {"role": "user", "content": "Name three Rust crates for HTTP."},
31
+ ],
32
+ }))
33
+ response = await client.chat(request)
34
+ print(response.choices[0].message.content)
35
+
36
+ asyncio.run(main())
37
+ ```
38
+
39
+ ## Provider routing
40
+
41
+ The model string's prefix selects the provider; build a request per backend:
42
+
43
+ ```python
44
+ ChatCompletionRequest.from_json('{"model":"anthropic/claude-sonnet-4-20250514","messages":[...]}')
45
+ ChatCompletionRequest.from_json('{"model":"google/gemini-2.0-flash","messages":[...]}')
46
+ ChatCompletionRequest.from_json('{"model":"groq/llama3-70b","messages":[...]}')
47
+ ChatCompletionRequest.from_json('{"model":"mistral/mistral-large-latest","messages":[...]}')
48
+ ChatCompletionRequest.from_json('{"model":"bedrock/anthropic.claude-v2","messages":[...]}')
49
+ ```
50
+
51
+ Set `model_hint` at construction to drop the prefix on every call:
52
+
53
+ ```python
54
+ client = create_client(api_key="sk-...", model_hint="openai")
55
+ # the request model can now omit the provider prefix:
56
+ request = ChatCompletionRequest.from_json('{"model":"gpt-4o","messages":[...]}')
57
+ await client.chat(request) # routes to OpenAI
58
+ ```
59
+
60
+ ## Notes
61
+
62
+ - Keys come from env vars (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, …); never
63
+ hardcode them.
64
+ - Without a prefix and without `model_hint`, routing fails.
65
+ - Python errors are typed exceptions exported from `liter_llm`:
66
+ `AuthenticationError`, `RateLimitedError`, `BadRequestError`,
67
+ `ContextWindowExceededError`, `ContentPolicyError`, `NotFoundError`,
68
+ `ServerError`, `ServiceUnavailableError`, `LiterLlmTimeoutError`,
69
+ `BudgetExceededError` — all subclasses of `LiterLlmError`.
@@ -0,0 +1,81 @@
1
+ ---
2
+ name: embeddings-and-search
3
+ description: Use when generating embeddings, calling the 12 web-search providers, or running OCR over documents with the 4 OCR providers through liter-llm. Covers embed, search, and ocr methods plus reranking.
4
+ ---
5
+
6
+ <!--
7
+ AI-RULEZ :: GENERATED FILE — DO NOT EDIT
8
+ Content-Hash: blake3:2486d6f9ae493f698ce3c5349658dacd142fb7dd3cd27a707c4acf3aa8877a55
9
+ Source-Hash: blake3:8e912c45817ab091b08e38cc7bf8e0a4d28b63546499306a907ade79e33dbe17
10
+ Schema-Version: v1
11
+ -->
12
+
13
+ # Embeddings and Search
14
+
15
+ liter-llm exposes embeddings, web search (12 providers), OCR (4 providers), and
16
+ reranking through the same `provider/model` routing convention.
17
+
18
+ ## Embeddings
19
+
20
+ ```python
21
+ import asyncio, os
22
+ from liter_llm import create_client
23
+ from liter_llm._internal_bindings import EmbeddingRequest
24
+
25
+ async def main() -> None:
26
+ client = create_client(api_key=os.environ["OPENAI_API_KEY"])
27
+ request = EmbeddingRequest.from_json(
28
+ '{"model":"openai/text-embedding-3-small","input":["first document","second document"]}'
29
+ )
30
+ response = await client.embed(request)
31
+ for item in response.data:
32
+ print(len(item.embedding))
33
+
34
+ asyncio.run(main())
35
+ ```
36
+
37
+ Many embedding models support dimension selection and base64 output; set
38
+ `dimensions` / `encoding_format` in the request where the provider allows it.
39
+
40
+ ## Web search (12 providers)
41
+
42
+ ```python
43
+ from liter_llm._internal_bindings import SearchRequest
44
+
45
+ client = create_client(api_key=os.environ["BRAVE_API_KEY"])
46
+ request = SearchRequest.from_json(
47
+ '{"model":"brave/web-search","query":"What is the Rust programming language?","max_results":5}'
48
+ )
49
+ response = await client.search(request)
50
+ for result in response.results:
51
+ print(result.title, result.url)
52
+ ```
53
+
54
+ ## OCR (4 providers)
55
+
56
+ ```python
57
+ from liter_llm._internal_bindings import OcrRequest
58
+
59
+ client = create_client(api_key=os.environ["MISTRAL_API_KEY"])
60
+ request = OcrRequest.from_json(
61
+ '{"model":"mistral/mistral-ocr-latest",'
62
+ '"document":{"type":"document_url","url":"https://example.com/invoice.pdf"}}'
63
+ )
64
+ response = await client.ocr(request)
65
+ for page in response.pages:
66
+ print(page.index, page.markdown[:100])
67
+ ```
68
+
69
+ ## Reranking
70
+
71
+ Build a `RerankRequest` (model, query, documents) and call `client.rerank(request)`
72
+ to score and order candidate documents against a query for retrieval pipelines —
73
+ combine it with `embed` for hybrid retrieval. Each result carries `index` and
74
+ `relevance_score`. Routing follows the same `provider/model` convention.
75
+
76
+ ## Notes
77
+
78
+ - Search and OCR providers each need their own API key (e.g. `BRAVE_API_KEY`,
79
+ `MISTRAL_API_KEY`); read them from env vars.
80
+ - See the upstream provider reference for the full list of the 12 search and 4
81
+ OCR backends and their model identifiers.
@@ -0,0 +1,214 @@
1
+ ---
2
+ name: liter-llm
3
+ description: >-
4
+ Universal LLM API client for 174 providers with native bindings for 14
5
+ languages. Use when writing code that calls LLM APIs via liter-llm in Python,
6
+ TypeScript, Rust, Go, Java, C#, Ruby, PHP, Elixir, WASM, or C, when running
7
+ the OpenAI-compatible proxy, or when calling LLMs through the MCP server.
8
+ Covers chat, streaming, tool calling, embeddings, image generation, speech,
9
+ transcription, moderation, web search, OCR, reranking, provider routing,
10
+ middleware, and configuration.
11
+ license: MIT
12
+ metadata:
13
+ author: xberg-io
14
+ version: "0.1.0"
15
+ repository: https://github.com/xberg-io/liter-llm
16
+ ---
17
+
18
+ <!--
19
+ AI-RULEZ :: GENERATED FILE — DO NOT EDIT
20
+ Content-Hash: blake3:4c225507a3afd50b57d77a64697e865deeb31b88a491ab3e8541cea22a5884ff
21
+ Source-Hash: blake3:8e912c45817ab091b08e38cc7bf8e0a4d28b63546499306a907ade79e33dbe17
22
+ Schema-Version: v1
23
+ -->
24
+
25
+ # Liter-LLM Universal LLM Client
26
+
27
+ Liter-LLM is a universal LLM API client with a Rust core and native bindings for
28
+ Python, TypeScript/Node.js, Go, Java, C#, Ruby, PHP, Elixir, WebAssembly, and C
29
+ (FFI). One unified interface reaches 174 providers (OpenAI, Anthropic, Google
30
+ Gemini, Groq, Mistral, Cohere, AWS Bedrock, Azure, and many more).
31
+
32
+ ## Capability map
33
+
34
+ - **Modalities** — chat completions, streaming, tool/function calling,
35
+ structured outputs, embeddings, image generation, audio (speech,
36
+ transcription), moderation, web search, OCR, reranking, batch,
37
+ and file operations.
38
+ - **Provider routing** — `provider/model` prefix selects the backend
39
+ (`openai/gpt-4o`, `anthropic/claude-sonnet-4-20250514`,
40
+ `gemini/gemini-2.0-flash`). Set `model_hint` to skip the prefix.
41
+ - **Middleware** — response caching, rate limiting (RPM/TPM), cost tracking,
42
+ budget enforcement (`hard` / `soft`), fallback chains, circuit-breaker
43
+ cooldown, and background health checks.
44
+ - **Search providers (12)** and **OCR providers (4)** — first-class `search`
45
+ and `ocr` methods routed by the same prefix convention.
46
+ - **Proxy server** — `liter-llm api` exposes an OpenAI-compatible gateway with
47
+ 22 REST endpoints, virtual API keys, budgets, and SSE streaming.
48
+ - **MCP server** — `liter-llm mcp` exposes 22 tools mirroring the proxy
49
+ endpoints, for MCP-compatible clients (Claude Code, Claude Desktop).
50
+ - **14 language bindings** — same surface, language-native naming (snake_case
51
+ for Python/Rust/Ruby/Go/Elixir/PHP, camelCase for TS/Node/WASM/C#/Java).
52
+
53
+ ## When to use the MCP server vs the SDK vs the proxy
54
+
55
+ | Use the … | When you want to … | Entry point |
56
+ |-----------|--------------------|-------------|
57
+ | **MCP server** | Let the agent call LLM APIs directly as tools, with no glue code | `liter-llm mcp --transport stdio` (this plugin auto-registers it) |
58
+ | **SDK / binding** | Write application code that calls LLMs in a specific language | `pip install liter-llm`, `cargo add liter-llm`, etc. |
59
+ | **Proxy** | Give many apps/teams a shared OpenAI-compatible endpoint with keys, budgets, and rate limits | `liter-llm api --config liter-llm-proxy.toml` |
60
+
61
+ Rule of thumb: reach for the MCP server inside an agent session; the SDK when
62
+ building software; the proxy when centralizing access for multiple consumers.
63
+
64
+ ## Installation
65
+
66
+ ### CLI (proxy + MCP server)
67
+
68
+ ```bash
69
+ # Homebrew (macOS / Linux)
70
+ brew install xberg-io/tap/liter-llm
71
+
72
+ # or run it without a persistent install (the CLI proxy package self-installs the binary)
73
+ npx @xberg-io/liter-llm-cli --help
74
+ uvx --from liter-llm-cli liter-llm --help
75
+
76
+ # or download a prebuilt binary from the latest GitHub release:
77
+ # https://github.com/xberg-io/liter-llm/releases/latest
78
+
79
+ # or build from source
80
+ cargo install liter-llm-cli
81
+
82
+ # or Docker (35MB image)
83
+ docker pull ghcr.io/xberg-io/liter-llm
84
+ ```
85
+
86
+ ### Language bindings
87
+
88
+ | Language | Install |
89
+ |----------|---------|
90
+ | Python | `pip install liter-llm` |
91
+ | Node.js | `pnpm add @xberg-io/liter-llm` |
92
+ | Rust | `cargo add liter-llm` |
93
+ | Go | `go get github.com/xberg-io/liter-llm/packages/go/v2` |
94
+ | Ruby | `gem install liter_llm` |
95
+ | PHP | `composer require xberg-io/liter-llm` |
96
+ | C# | `dotnet add package XbergIo.LiterLlm` |
97
+ | WASM | `pnpm add @xberg-io/liter-llm-wasm` |
98
+
99
+ ## Quick start (Python, async)
100
+
101
+ ```python
102
+ import asyncio
103
+ import os
104
+ from liter_llm import create_client
105
+ from liter_llm._internal_bindings import ChatCompletionRequest
106
+
107
+ async def main() -> None:
108
+ client = create_client(api_key=os.environ["OPENAI_API_KEY"])
109
+ request = ChatCompletionRequest.from_json(
110
+ '{"model":"openai/gpt-4o","messages":[{"role":"user","content":"Hello!"}]}'
111
+ )
112
+ response = await client.chat(request)
113
+ print(response.choices[0].message.content)
114
+
115
+ asyncio.run(main())
116
+ ```
117
+
118
+ ## Provider routing
119
+
120
+ The prefix before `/` in the request's `model` selects the provider:
121
+
122
+ ```python
123
+ ChatCompletionRequest.from_json('{"model":"openai/gpt-4o","messages":[...]}')
124
+ ChatCompletionRequest.from_json('{"model":"anthropic/claude-sonnet-4-20250514","messages":[...]}')
125
+ ChatCompletionRequest.from_json('{"model":"gemini/gemini-2.0-flash","messages":[...]}')
126
+ ChatCompletionRequest.from_json('{"model":"groq/llama3-70b","messages":[...]}')
127
+ ```
128
+
129
+ API keys come from environment variables: `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`,
130
+ `GEMINI_API_KEY`, `GROQ_API_KEY`, `MISTRAL_API_KEY`, `CO_API_KEY`, and AWS
131
+ credentials for Bedrock.
132
+
133
+ ## Configuration
134
+
135
+ Pass basic options to `create_client(...)` (`api_key`, `base_url`,
136
+ `timeout_secs`, `max_retries`, `model_hint`), or set the full surface in a
137
+ `liter-llm.toml` (SDK) / `liter-llm-proxy.toml` (proxy) — both auto-discover
138
+ from the cwd upward.
139
+
140
+ ```toml
141
+ # liter-llm.toml
142
+ api_key = "${OPENAI_API_KEY}"
143
+ model_hint = "openai"
144
+ timeout_secs = 120
145
+ max_retries = 5
146
+
147
+ [cache]
148
+ max_entries = 512
149
+ ttl_seconds = 600
150
+
151
+ [budget]
152
+ global_limit = 50.0
153
+ enforcement = "hard"
154
+
155
+ [rate_limit]
156
+ rpm = 60
157
+ tpm = 100000
158
+ ```
159
+
160
+ | Option | Default | Description |
161
+ |--------|---------|-------------|
162
+ | `api_key` | required | Provider key, wrapped in `SecretString` (never logged). |
163
+ | `base_url` | from registry | Override the provider base URL. |
164
+ | `model_hint` | none | Pre-resolve a provider, skipping the prefix lookup. |
165
+ | `timeout_secs` | 60 | Request timeout in seconds. |
166
+ | `max_retries` | 3 | Retries on 429/5xx with exponential backoff. |
167
+ | `cache` | none | `max_entries`, `ttl_seconds`. |
168
+ | `budget` | none | `global_limit`, `model_limits`, `enforcement`. |
169
+ | `rate_limit` | none | `rpm`, `tpm`. |
170
+ | `cost_tracking` | false | Per-request cost tracking. |
171
+ | `tracing` | false | OpenTelemetry spans. |
172
+
173
+ ## Proxy server
174
+
175
+ ```bash
176
+ liter-llm api --config liter-llm-proxy.toml
177
+ ```
178
+
179
+ 22 OpenAI-compatible endpoints, model routing by prefix, virtual API keys,
180
+ per-key RPM/TPM limits, cost tracking, budget enforcement, response caching, SSE
181
+ streaming, and an OpenAPI 3.1 spec at `/openapi.json`.
182
+
183
+ ## MCP server
184
+
185
+ ```bash
186
+ liter-llm mcp --transport stdio # for Claude Code / Claude Desktop
187
+ liter-llm mcp --transport http --port 3001
188
+ ```
189
+
190
+ This plugin auto-registers the stdio server via `scripts/mcp-launch.sh`. The 22
191
+ tools mirror the proxy endpoints: `chat`, `embed`, `generate_image`, `speech`,
192
+ `transcribe`, `moderate`, `rerank`, `search`, `ocr`, `list_models`; file ops
193
+ (`create_file`, `list_files`, `retrieve_file`, `delete_file`, `file_content`);
194
+ batch ops (`create_batch`, `list_batches`, `retrieve_batch`, `cancel_batch`);
195
+ and Responses API (`create_response`, `retrieve_response`, `cancel_response`).
196
+
197
+ ## Common pitfalls
198
+
199
+ 1. **Provider prefix is required** — use `"provider/model"` unless `model_hint`
200
+ is set, or routing fails.
201
+ 2. **API keys are SecretString** — never logged or serialized. Read from env
202
+ vars; never hardcode.
203
+ 3. **Python methods are async** — `await` inside an async context.
204
+ 4. **Naming conventions differ** — camelCase for TS/Node/WASM/C#/Java,
205
+ snake_case elsewhere.
206
+ 5. **Streaming chunks may have null content** — null-check
207
+ `chunk.choices[0].delta.content` before use.
208
+ 6. **Budget modes** — `hard` rejects over-budget requests; `soft` logs and
209
+ allows.
210
+
211
+ ## Additional resources
212
+
213
+ - Upstream docs: <https://docs.liter-llm.xberg.io>
214
+ - GitHub: <https://github.com/xberg-io/liter-llm>
@@ -0,0 +1,74 @@
1
+ ---
2
+ name: running-the-proxy
3
+ description: Use when running the `liter-llm api` OpenAI-compatible gateway — virtual keys, per-key rate limits, budgets, cost tracking, and model routing. Covers the TOML config and the 22 REST endpoints.
4
+ ---
5
+
6
+ <!--
7
+ AI-RULEZ :: GENERATED FILE — DO NOT EDIT
8
+ Content-Hash: blake3:f64a695a6f03738acb7f80e2d91d2446346006e3a2c4858b5fe87f88f14c86f5
9
+ Source-Hash: blake3:8e912c45817ab091b08e38cc7bf8e0a4d28b63546499306a907ade79e33dbe17
10
+ Schema-Version: v1
11
+ -->
12
+
13
+ # Running the Proxy
14
+
15
+ `liter-llm api` is a drop-in OpenAI-compatible gateway: 22 REST endpoints that
16
+ route to 174 providers, with multi-tenant virtual keys, rate limits, budgets,
17
+ and cost tracking.
18
+
19
+ ## Start it
20
+
21
+ ```bash
22
+ liter-llm api --config liter-llm-proxy.toml
23
+ ```
24
+
25
+ The proxy auto-discovers `liter-llm-proxy.toml` in the current directory.
26
+
27
+ ## Configuration
28
+
29
+ ```toml
30
+ [server]
31
+ host = "0.0.0.0"
32
+ port = 4000
33
+
34
+ [general]
35
+ master_key = "${LITER_LLM_MASTER_KEY}"
36
+
37
+ # Each [[models]] entry maps a routable name to a provider/model and its key.
38
+ [[models]]
39
+ name = "gpt-4o"
40
+ provider_model = "openai/gpt-4o"
41
+ api_key = "${OPENAI_API_KEY}"
42
+
43
+ [[models]]
44
+ name = "claude-sonnet"
45
+ provider_model = "anthropic/claude-sonnet-4-20250514"
46
+ api_key = "${ANTHROPIC_API_KEY}"
47
+
48
+ # Virtual keys scope which configured model names a caller may use.
49
+ [[keys]]
50
+ key = "sk-team-frontend"
51
+ models = ["gpt-4o", "claude-sonnet"]
52
+ rpm = 60
53
+ tpm = 100000
54
+ budget_limit = 50.0
55
+ ```
56
+
57
+ `${ENV_VAR}` interpolation keeps secrets out of the file.
58
+
59
+ ## Call it like OpenAI
60
+
61
+ ```bash
62
+ curl http://localhost:4000/v1/chat/completions \
63
+ -H "Authorization: Bearer sk-team-frontend" \
64
+ -d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "Hello"}]}'
65
+ ```
66
+
67
+ ## Notes
68
+
69
+ - Virtual keys scope which models a caller may use and carry their own RPM/TPM
70
+ and budget limits; the `master_key` administers them.
71
+ - The OpenAPI 3.1 spec is served at `/openapi.json`.
72
+ - Endpoints cover chat, embeddings, images, audio, moderations, files, batches,
73
+ responses, and model listing.
74
+ - Docker: `docker run -p 4000:4000 -e LITER_LLM_MASTER_KEY=sk-key ghcr.io/xberg-io/liter-llm`.
@@ -0,0 +1,63 @@
1
+ ---
2
+ name: streaming-responses
3
+ description: Use when streaming tokens incrementally from an LLM via liter-llm over SSE or async iterators. Covers chat_stream, delta handling, and null-content chunks.
4
+ ---
5
+
6
+ <!--
7
+ AI-RULEZ :: GENERATED FILE — DO NOT EDIT
8
+ Content-Hash: blake3:13410510899be94ef40712ffcb27a284ef4ac4977b6e45b38b014d60e913f995
9
+ Source-Hash: blake3:8e912c45817ab091b08e38cc7bf8e0a4d28b63546499306a907ade79e33dbe17
10
+ Schema-Version: v1
11
+ -->
12
+
13
+ # Streaming Responses
14
+
15
+ Use `chat_stream(...)` to receive tokens as they are produced instead of waiting
16
+ for the full completion. The proxy streams over SSE; bindings expose async
17
+ iterators.
18
+
19
+ ## Python
20
+
21
+ ```python
22
+ import asyncio, os
23
+ from liter_llm import create_client
24
+ from liter_llm._internal_bindings import ChatCompletionRequest
25
+
26
+ async def main() -> None:
27
+ client = create_client(api_key=os.environ["OPENAI_API_KEY"])
28
+ request = ChatCompletionRequest.from_json(
29
+ '{"model":"openai/gpt-4o","messages":[{"role":"user","content":"Tell me a story"}],"stream":true}'
30
+ )
31
+ async for chunk in client.chat_stream(request):
32
+ if chunk.choices and chunk.choices[0].delta.content:
33
+ print(chunk.choices[0].delta.content, end="", flush=True)
34
+ print()
35
+
36
+ asyncio.run(main())
37
+ ```
38
+
39
+ ## TypeScript
40
+
41
+ ```typescript
42
+ import { createClient } from "@xberg-io/liter-llm";
43
+
44
+ const client = createClient(process.env.OPENAI_API_KEY!);
45
+ const chunks = await client.chatStream({
46
+ model: "openai/gpt-4o",
47
+ messages: [{ role: "user", content: "Tell me a story" }],
48
+ });
49
+ for await (const chunk of chunks) {
50
+ process.stdout.write(chunk.choices?.[0]?.delta?.content ?? "");
51
+ }
52
+ ```
53
+
54
+ ## Notes
55
+
56
+ - The first and last chunks often carry null content. Always null-check
57
+ `chunk.choices[0].delta.content` (Python) or
58
+ `chunk.choices[0]?.delta?.content` (TypeScript) before using it.
59
+ - Tool-call deltas arrive in `delta.tool_calls` (Python) /
60
+ `delta.toolCalls` (TypeScript); accumulate `function.arguments` fragments
61
+ across chunks before parsing.
62
+ - Through the proxy, request streaming with `"stream": true` on
63
+ `/v1/chat/completions`.
@@ -0,0 +1,77 @@
1
+ ---
2
+ name: tool-calling
3
+ description: Use when defining functions/tools for an LLM to call through liter-llm, or requesting structured JSON outputs. Covers tool schemas, tool_calls handling, and response formats.
4
+ ---
5
+
6
+ <!--
7
+ AI-RULEZ :: GENERATED FILE — DO NOT EDIT
8
+ Content-Hash: blake3:636c5d6448b2e3c003bd5caec347391513d72e415ceaef0740241141bc363b35
9
+ Source-Hash: blake3:8e912c45817ab091b08e38cc7bf8e0a4d28b63546499306a907ade79e33dbe17
10
+ Schema-Version: v1
11
+ -->
12
+
13
+ # Tool Calling
14
+
15
+ Pass a `tools` array of function definitions; the model may respond with
16
+ `tool_calls` instead of (or alongside) text. Execute the named function and feed
17
+ the result back as a `tool` message.
18
+
19
+ ## Python
20
+
21
+ ```python
22
+ import asyncio, json, os
23
+ from liter_llm import create_client
24
+ from liter_llm._internal_bindings import ChatCompletionRequest
25
+
26
+ payload = {
27
+ "model": "openai/gpt-4o",
28
+ "messages": [{"role": "user", "content": "What is the weather in Berlin?"}],
29
+ "tools": [
30
+ {
31
+ "type": "function",
32
+ "function": {
33
+ "name": "get_weather",
34
+ "description": "Get the current weather for a location",
35
+ "parameters": {
36
+ "type": "object",
37
+ "properties": {
38
+ "location": {"type": "string", "description": "City name"},
39
+ },
40
+ "required": ["location"],
41
+ },
42
+ },
43
+ }
44
+ ],
45
+ "tool_choice": "auto",
46
+ }
47
+
48
+ async def main() -> None:
49
+ client = create_client(api_key=os.environ["OPENAI_API_KEY"])
50
+ request = ChatCompletionRequest.from_json(json.dumps(payload))
51
+ response = await client.chat(request)
52
+ for call in response.choices[0].message.tool_calls or []:
53
+ print(call.function.name, call.function.arguments) # arguments is a JSON string
54
+
55
+ asyncio.run(main())
56
+ ```
57
+
58
+ ## Structured outputs
59
+
60
+ Request strict JSON with `response_format`:
61
+
62
+ ```python
63
+ request = ChatCompletionRequest.from_json(json.dumps({
64
+ "model": "openai/gpt-4o",
65
+ "messages": [{"role": "user", "content": "Extract name and age as JSON."}],
66
+ "response_format": {"type": "json_object"},
67
+ }))
68
+ response = await client.chat(request)
69
+ ```
70
+
71
+ ## Notes
72
+
73
+ - `function.arguments` is a JSON **string** — parse it before use.
74
+ - Append each tool result as a message with `role="tool"` and the matching
75
+ `tool_call_id`, then call `chat` again to let the model continue.
76
+ - Tool support and JSON-mode availability vary by provider; check the provider
77
+ reference if a model ignores `tools`.
@@ -0,0 +1,63 @@
1
+ ---
2
+ name: using-the-mcp-server
3
+ description: Use when calling LLM APIs through the liter-llm MCP server's 22 tools, and to decide when MCP beats the CLI or SDK. Covers the tool surface, the auto-installing launcher, and authentication.
4
+ ---
5
+
6
+ <!--
7
+ AI-RULEZ :: GENERATED FILE — DO NOT EDIT
8
+ Content-Hash: blake3:bb42aaf38dca362b154240ddbee4e03bb76614ff452facc744f7d560cfc6d523
9
+ Source-Hash: blake3:8e912c45817ab091b08e38cc7bf8e0a4d28b63546499306a907ade79e33dbe17
10
+ Schema-Version: v1
11
+ -->
12
+
13
+ # Using the MCP Server
14
+
15
+ The `liter-llm` MCP server exposes 22 tools that mirror the proxy's REST
16
+ endpoints, so an MCP-compatible client (Claude Code, Claude Desktop) can call
17
+ 174 LLM providers as tools with no glue code.
18
+
19
+ ## How it runs in this plugin
20
+
21
+ The plugin auto-registers the server. Its command is the bundled launcher
22
+ `scripts/mcp-launch.sh`, which execs `liter-llm mcp --transport stdio`. On first
23
+ run the launcher resolves a `liter-llm` binary: it reuses one on `PATH`, then
24
+ tries `npx`/`uvx`, then Homebrew, then downloads a checksum-verified prebuilt
25
+ from the tool's latest GitHub release. Override with
26
+ `LITER_LLM_LAUNCHER=auto|npx|uvx|brew|download`.
27
+
28
+ To run it manually:
29
+
30
+ ```bash
31
+ liter-llm mcp --transport stdio # for Claude Code / Claude Desktop
32
+ liter-llm mcp --transport http --port 3001
33
+ ```
34
+
35
+ ## The 22 tools
36
+
37
+ Modality tools: `chat`, `embed`, `generate_image`, `speech`, `transcribe`,
38
+ `moderate`, `rerank`, `search`, `ocr`.
39
+
40
+ Model listing: `list_models`.
41
+
42
+ File operations: `create_file`, `list_files`, `retrieve_file`, `delete_file`,
43
+ `file_content`.
44
+
45
+ Batch operations: `create_batch`, `list_batches`, `retrieve_batch`,
46
+ `cancel_batch`.
47
+
48
+ Responses API: `create_response`, `retrieve_response`, `cancel_response`.
49
+
50
+ ## Authentication
51
+
52
+ stdio has no per-request auth headers, so the server binds auth once at startup
53
+ via `[mcp]` config: set `mcp.stdio_key_id` to bind a specific virtual key, or
54
+ `mcp.stdio_trust_local = true` for fully trusted local environments. Provider
55
+ keys still come from env vars.
56
+
57
+ ## When to prefer MCP over the CLI or SDK
58
+
59
+ - **Prefer MCP** inside an agent session: the agent invokes tools directly, with
60
+ no code to write or process to manage.
61
+ - **Prefer the SDK** when building application software in a specific language.
62
+ - **Prefer the proxy** when many apps or teams need a shared OpenAI-compatible
63
+ endpoint with keys, budgets, and rate limits.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # liter-llm
2
2
 
3
- Universal LLM API client for 165 providers — chat, streaming, tools, embeddings, search, OCR, plus an OpenAI-compatible proxy and an MCP server, in your agent.
3
+ Universal LLM API client for 174 providers — chat, streaming, tools, embeddings, search, OCR, plus an OpenAI-compatible proxy and an MCP server, in your agent.
4
4
 
5
5
  <!-- ~keep TODO: add screenshot -->
6
6
 
@@ -47,7 +47,7 @@ Set provider API keys via environment variables (`OPENAI_API_KEY`,
47
47
 
48
48
  | Skill | Trigger |
49
49
  |-------|---------|
50
- | **liter-llm** | Universal LLM client surface — chat, streaming, tools, embeddings, image/audio, moderation, search, OCR, reranking — across 165 providers and 14 language bindings. Use when writing LLM code in any supported language or choosing between the SDK, proxy, and MCP server. |
50
+ | **liter-llm** | Universal LLM client surface — chat, streaming, tools, embeddings, image/audio, moderation, search, OCR, reranking — across 174 providers and 14 language bindings. Use when writing LLM code in any supported language or choosing between the SDK, proxy, and MCP server. |
51
51
  | **calling-llms** | Use when sending chat completions and routing to a specific provider via the `provider/model` prefix. |
52
52
  | **streaming-responses** | Use when streaming tokens incrementally over SSE or async iterators. |
53
53
  | **tool-calling** | Use when defining functions/tools for the model to call, or requesting structured outputs. |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xberg-io/opencode-liter-llm",
3
- "version": "2.1.2",
4
- "description": "Universal LLM API client for 165 providers — chat, streaming, tools, embeddings, search, OCR, plus an OpenAI-compatible proxy and an MCP server.",
3
+ "version": "2.2.0",
4
+ "description": "Universal LLM API client for 174 providers — chat, streaming, tools, embeddings, search, OCR, plus an OpenAI-compatible proxy and an MCP server.",
5
5
  "keywords": [
6
6
  "llm",
7
7
  "openai",
@@ -23,9 +23,10 @@
23
23
  "files": [
24
24
  ".opencode/",
25
25
  "assets/",
26
- "README.md"
26
+ "README.md",
27
+ "scripts/"
27
28
  ],
28
29
  "dependencies": {
29
- "@opencode-ai/plugin": "^1.17.8"
30
+ "@opencode/plugin": "^2.0.20"
30
31
  }
31
32
  }
@@ -0,0 +1,225 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ REPO="xberg-io/liter-llm"
5
+ NPM_PKG="@xberg-io/liter-llm-cli"
6
+ PYPI_PKG="liter-llm-cli"
7
+
8
+ log() { printf 'liter-llm-launch: %s\n' "$*" >&2; }
9
+ die() {
10
+ log "error: $*"
11
+ exit 1
12
+ }
13
+
14
+ LAUNCHER="${LITER_LLM_LAUNCHER:-auto}"
15
+ case "$LAUNCHER" in
16
+ auto | npx | uvx | brew | download) ;;
17
+ *) die "invalid LITER_LLM_LAUNCHER='$LAUNCHER' (expected auto|npx|uvx|brew|download)" ;;
18
+ esac
19
+
20
+ want() { [ "$LAUNCHER" = "auto" ] || [ "$LAUNCHER" = "$1" ]; }
21
+
22
+ PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-}"
23
+ if [ -z "$PLUGIN_ROOT" ]; then
24
+ PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
25
+ fi
26
+
27
+ BINARY_NAME="liter-llm"
28
+ case "$(uname -s)" in
29
+ MINGW* | MSYS* | CYGWIN* | Windows_NT) BINARY_NAME="liter-llm.exe" ;;
30
+ esac
31
+ BIN_DIR="$PLUGIN_ROOT/bin"
32
+ BIN="$BIN_DIR/$BINARY_NAME"
33
+
34
+ have() { command -v "$1" >/dev/null 2>&1; }
35
+
36
+ runs_ok() { [ -x "$1" ] && "$1" --version >/dev/null 2>&1; }
37
+
38
+ if runs_ok "$BIN"; then
39
+ log "using cached liter-llm at $BIN"
40
+ exec "$BIN" "$@"
41
+ fi
42
+ if have "$BINARY_NAME"; then
43
+ PATH_BIN="$(command -v "$BINARY_NAME")"
44
+ if runs_ok "$PATH_BIN"; then
45
+ log "using liter-llm on PATH ($PATH_BIN)"
46
+ exec "$PATH_BIN" "$@"
47
+ fi
48
+ fi
49
+
50
+ if want npx && have npx; then
51
+ log "probing npx $NPM_PKG@latest ..."
52
+ scratch="$(mktemp -d)"
53
+ if (cd "$scratch" && npx -y "$NPM_PKG@latest" --version >/dev/null 2>&1); then
54
+ log "launching via npx $NPM_PKG@latest"
55
+ cd "$scratch"
56
+ exec npx -y "$NPM_PKG@latest" "$@"
57
+ fi
58
+ rm -rf "$scratch"
59
+ log "npx $NPM_PKG not available (no CLI bin yet); falling through"
60
+ fi
61
+
62
+ if want uvx && have uvx; then
63
+ log "probing uvx --from $PYPI_PKG $BINARY_NAME ..."
64
+ if uvx --from "$PYPI_PKG" "$BINARY_NAME" --version >/dev/null 2>&1; then
65
+ log "launching via uvx --from $PYPI_PKG $BINARY_NAME"
66
+ exec uvx --from "$PYPI_PKG" "$BINARY_NAME" "$@"
67
+ fi
68
+ log "uvx $PYPI_PKG not available (no CLI entry point yet); falling through"
69
+ fi
70
+
71
+ if want brew && have brew; then
72
+ log "installing via 'brew install xberg-io/tap/liter-llm' ..."
73
+ if brew install xberg-io/tap/liter-llm >&2; then
74
+ if have "$BINARY_NAME"; then
75
+ BREW_BIN="$(command -v "$BINARY_NAME")"
76
+ runs_ok "$BREW_BIN" && exec "$BREW_BIN" "$@"
77
+ fi
78
+ log "brew install reported success but liter-llm is not on PATH; falling through"
79
+ else
80
+ log "brew install failed; falling through"
81
+ fi
82
+ fi
83
+
84
+ if have curl; then
85
+ fetch() { curl -fsSL --retry 3 -o "$2" "$1"; }
86
+ fetch_stdout() { curl -fsSL --retry 3 "$1"; }
87
+ elif have wget; then
88
+ fetch() { wget -q -O "$2" "$1"; }
89
+ fetch_stdout() { wget -q -O - "$1"; }
90
+ else
91
+ fetch() { return 1; }
92
+ fetch_stdout() { return 1; }
93
+ fi
94
+
95
+ if have sha256sum; then
96
+ sha256() { sha256sum "$1" | awk '{print $1}'; }
97
+ elif have shasum; then
98
+ sha256() { shasum -a 256 "$1" | awk '{print $1}'; }
99
+ else
100
+ sha256() { return 1; }
101
+ fi
102
+
103
+ try_download() {
104
+ if ! have curl && ! have wget; then
105
+ log "download: no curl or wget available; falling through"
106
+ return 1
107
+ fi
108
+
109
+ arch="$(uname -m)"
110
+ local target ext
111
+ case "$(uname -s)" in
112
+ Darwin)
113
+ case "$arch" in
114
+ arm64 | aarch64) target="aarch64-apple-darwin" ;;
115
+ *)
116
+ log "download: Intel macOS (x86_64) prebuilt is not shipped; falling through"
117
+ return 1
118
+ ;;
119
+ esac
120
+ ext="tar.gz"
121
+ ;;
122
+ Linux)
123
+ case "$arch" in
124
+ aarch64 | arm64) target="aarch64-unknown-linux-gnu" ;;
125
+ *) target="x86_64-unknown-linux-gnu" ;;
126
+ esac
127
+ ext="tar.gz"
128
+ ;;
129
+ MINGW* | MSYS* | CYGWIN* | Windows_NT)
130
+ target="x86_64-pc-windows-msvc"
131
+ ext="zip"
132
+ ;;
133
+ *)
134
+ log "download: unsupported platform $(uname -s) $arch; falling through"
135
+ return 1
136
+ ;;
137
+ esac
138
+
139
+ local api_json tag version
140
+ api_json="$(fetch_stdout "https://api.github.com/repos/${REPO}/releases/latest" 2>/dev/null)" ||
141
+ {
142
+ log "download: could not query latest release; falling through"
143
+ return 1
144
+ }
145
+ tag="$(printf '%s' "$api_json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)"
146
+ [ -n "$tag" ] || {
147
+ log "download: could not parse tag_name from API; falling through"
148
+ return 1
149
+ }
150
+ version="${tag#v}"
151
+
152
+ local stem base_url asset asset_url sums_url
153
+ stem="liter-llm-${version}-${target}"
154
+ base_url="https://github.com/${REPO}/releases/download/${tag}"
155
+ asset="${stem}.${ext}"
156
+ asset_url="${base_url}/${asset}"
157
+ sums_url="${base_url}/SHA256SUMS-${version}.txt"
158
+
159
+ local tmp
160
+ tmp="$(mktemp -d)"
161
+ # shellcheck disable=SC2064
162
+ trap "rm -rf '$tmp'" RETURN
163
+
164
+ log "downloading $asset from $tag ..."
165
+ if ! fetch "$asset_url" "$tmp/$asset"; then
166
+ log "download: asset not found ($asset_url); falling through"
167
+ return 1
168
+ fi
169
+
170
+ if sha256 "$tmp/$asset" >/dev/null 2>&1 && fetch "$sums_url" "$tmp/checksums.txt" 2>/dev/null; then
171
+ local expected actual
172
+ expected="$(awk -v f="$asset" '{name=$NF; sub(/^[*]/, "", name); if (name == f) print $1}' "$tmp/checksums.txt")"
173
+ if [ -n "$expected" ]; then
174
+ actual="$(sha256 "$tmp/$asset")"
175
+ [ "$expected" = "$actual" ] || die "checksum mismatch for $asset (expected $expected, got $actual)"
176
+ log "checksum verified"
177
+ else
178
+ log "WARNING: no checksum entry for $asset in SHA256SUMS-${version}.txt; proceeding over HTTPS unverified"
179
+ fi
180
+ else
181
+ log "WARNING: could not verify checksum (no sha256 tool or no SHA256SUMS file); proceeding over HTTPS unverified"
182
+ fi
183
+
184
+ local ex src_bin
185
+ ex="$tmp/extracted"
186
+ mkdir -p "$ex"
187
+ case "$ext" in
188
+ tar.gz) tar -xzf "$tmp/$asset" -C "$ex" ;;
189
+ zip)
190
+ have unzip || {
191
+ log "download: need unzip to extract $asset; falling through"
192
+ return 1
193
+ }
194
+ unzip -qo "$tmp/$asset" -d "$ex"
195
+ ;;
196
+ esac
197
+ src_bin="$ex/$stem/$BINARY_NAME"
198
+ [ -f "$src_bin" ] || {
199
+ log "download: $BINARY_NAME not found inside $asset; falling through"
200
+ return 1
201
+ }
202
+
203
+ rm -rf "$BIN_DIR"
204
+ mkdir -p "$BIN_DIR"
205
+ mv "$src_bin" "$BIN"
206
+ chmod +x "$BIN"
207
+ log "installed liter-llm $version to $BIN"
208
+ runs_ok "$BIN" || {
209
+ log "download: installed binary failed to run; falling through"
210
+ return 1
211
+ }
212
+ return 0
213
+ }
214
+
215
+ if want download; then
216
+ if try_download; then
217
+ exec "$BIN" "$@"
218
+ fi
219
+ fi
220
+
221
+ die "could not locate or install liter-llm. Install it with one of:
222
+ brew install xberg-io/tap/liter-llm
223
+ cargo install liter-llm-cli
224
+ or download a prebuilt archive from https://github.com/${REPO}/releases/latest
225
+ then re-run, or set LITER_LLM_LAUNCHER to force a method (auto|npx|uvx|brew|download)."