@konduiteu/openclaw 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,10 +18,17 @@ openclaw plugins install npm:@konduiteu/openclaw
18
18
  ```
19
19
 
20
20
  Set `KONDUIT_API_KEY` to a key minted in the [konduit console](https://console.konduit.eu),
21
- or run `openclaw onboard --konduit-api-key kdt-…`. The key needs the
22
- `usage:read` scope for the card to show a balance; a key created without any
23
- scopes is unrestricted and works as it is. A key narrowed to `chat:write` still
24
- completes, and the card says why it shows nothing.
21
+ or run `openclaw onboard --konduit-api-key kdt-…`. A key created without any
22
+ scopes is unrestricted and works as it is. A key that carries scopes needs:
23
+
24
+ | Scope | What stops working without it |
25
+ | --- | --- |
26
+ | `chat:write` | inference — `POST /v1/chat/completions` |
27
+ | `models:read` | the model list. The plugin reads konduit's catalog at startup and again when it resolves a model it does not list, so a key without this scope leaves you with no konduit models to choose from |
28
+ | `usage:read` | the balance and the limits on the provider card, nothing else |
29
+
30
+ konduit's scopes are a flat allowlist, so `chat:write` does not imply
31
+ `models:read`; a scoped key is refused on every route it does not name.
25
32
 
26
33
  If you had configured konduit by hand under `models.providers.konduit`, keep
27
34
  it: OpenClaw merges your entries with the plugin's by model id, and your
@@ -52,14 +59,21 @@ context window, output cap and price, generated from `GET /v1/models`:
52
59
  KONDUIT_API_KEY=kdt-… npm run catalog
53
60
  ```
54
61
 
62
+ The catalog is authenticated because it carries prices, so this needs a key
63
+ with `models:read` — and nothing else. That is the scope the repository's
64
+ `KONDUIT_API_KEY` secret carries, and all `catalog:check` in CI asks for.
65
+
55
66
  That includes deployments konduit marks `deprecated`: the status discourages
56
67
  them, it does not switch them off, and a model you already have in your config
57
68
  should not vanish from the list because of a label. Only `retired` deployments
58
69
  are left out. The default model is never a deprecated one.
59
70
 
60
- CI checks the committed list against the live catalog. Live discovery is on
61
- as well, so a deployment konduit adds appears before the next release at
62
- cost zero until the generator has written its price.
71
+ CI checks the committed list against the live catalog. Live discovery is on as
72
+ well, and it reads konduit's catalog with the same projection the generator
73
+ uses: a deployment konduit adds appears before the next release with its real
74
+ context window, output cap, capabilities and price, and can be selected right
75
+ away. Deployments that do not serve chat — the embedding ones — stay out of the
76
+ model list rather than being offered as something to talk to.
63
77
 
64
78
  **One caveat.** OpenClaw prices models in US dollars per million tokens and
65
79
  has no currency field. konduit prices in euros; the euro figures are stored
@@ -0,0 +1,69 @@
1
+ // konduit's own deployment id grammar: a provider slug, a slash, the model
2
+ // name, and an optional `:variant`. The model segment stays free-form because
3
+ // konduit does not control what characters an upstream model name contains —
4
+ // apart from `@`, which is the whole reason this is checked here.
5
+ //
6
+ // The separator used to be `@` and moved to `:` on 2026-09-11, because enough
7
+ // of the ecosystem reads `@` as an instance selector to corrupt an id in
8
+ // transit. These ids are written into a manifest that lands in every user's
9
+ // OpenClaw config, so shipping the old spelling would plant it where it is
10
+ // hardest to take back. The same pattern guards konduit's live canary.
11
+ const DEPLOYMENT_ID = /^[a-z0-9-]+\/[^:@]+(:[a-z0-9.-]+)?$/;
12
+ const PRICING_UNIT = "micro_eur_per_million_tokens";
13
+ const MICRO_PER_UNIT = 1_000_000;
14
+ // konduit reports max_output_tokens as null for a deployment whose operator
15
+ // publishes no cap. OpenClaw needs a number; this is a conservative one.
16
+ const FALLBACK_MAX_TOKENS = 4096;
17
+ // konduit serves a deployment while its status is active or deprecated —
18
+ // deprecated is discouraged, not switched off, and a user whose config names
19
+ // one would find it missing from the catalog if we dropped it. `retired`, and
20
+ // any status this generator has not seen, is left out rather than advertised.
21
+ const SERVABLE_STATUS = new Set(["active", "deprecated"]);
22
+ function isServableChat(model) {
23
+ return model.modality === "chat" && SERVABLE_STATUS.has(model.deployment.status);
24
+ }
25
+ /** Servable chat deployments, sorted by id so two runs produce one diff. */
26
+ export function mapCatalog(models) {
27
+ return models
28
+ .filter(isServableChat)
29
+ .map(mapModel)
30
+ .sort((a, b) => a.id.localeCompare(b.id));
31
+ }
32
+ /**
33
+ * The id the manifest should name as its default: the one it already names
34
+ * while konduit still serves it — deprecated included, since it still answers —
35
+ * otherwise the first active deployment in id order, so a fresh manifest never
36
+ * defaults to a model whose operator is winding it down. Empty when konduit
37
+ * serves no chat deployment, which the generator refuses to write anyway.
38
+ */
39
+ export function pickDefaultModel(models, current) {
40
+ const servable = models.filter(isServableChat).sort((a, b) => a.id.localeCompare(b.id));
41
+ if (servable.some((model) => model.id === current))
42
+ return current;
43
+ const active = servable.find((model) => model.deployment.status === "active");
44
+ return (active ?? servable[0])?.id ?? "";
45
+ }
46
+ export function mapModel(model) {
47
+ if (!DEPLOYMENT_ID.test(model.id)) {
48
+ throw new Error(`deployment id ${model.id} is not provider/model[:variant]; konduit does not accept '@' anywhere and this catalog will not publish it`);
49
+ }
50
+ if (model.pricing.unit !== PRICING_UNIT) {
51
+ throw new Error(`unexpected pricing unit ${model.pricing.unit} on ${model.id}; this generator understands ${PRICING_UNIT}`);
52
+ }
53
+ const input = model.pricing.input / MICRO_PER_UNIT;
54
+ const output = (model.pricing.output ?? model.pricing.input) / MICRO_PER_UNIT;
55
+ return {
56
+ id: model.id,
57
+ name: model.display_name,
58
+ reasoning: model.reasoning,
59
+ input: ["text"],
60
+ contextWindow: model.context_window,
61
+ maxTokens: model.max_output_tokens ?? FALLBACK_MAX_TOKENS,
62
+ cost: { input, output, cacheRead: 0, cacheWrite: 0 },
63
+ compat: {
64
+ supportsUsageInStreaming: model.capabilities.streaming,
65
+ supportsTools: model.capabilities.tools,
66
+ maxTokensField: "max_tokens",
67
+ },
68
+ };
69
+ }
package/dist/catalog.js CHANGED
@@ -5,6 +5,7 @@
5
5
  // vitest; ../openclaw.plugin.json is the package root from both.
6
6
  import { createRequire } from "node:module";
7
7
  import { buildManifestModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-shared";
8
+ import { mapCatalog } from "./catalog-mapping.js";
8
9
  export const PROVIDER_ID = "konduit";
9
10
  export const manifest = createRequire(import.meta.url)("../openclaw.plugin.json");
10
11
  const catalog = manifest.modelCatalog.providers.konduit;
@@ -18,3 +19,93 @@ export function buildKonduitProvider() {
18
19
  models: structuredClone(buildManifestModelProviderConfig({ providerId: PROVIDER_ID, catalog: catalog }).models),
19
20
  };
20
21
  }
22
+ /** The manifest's models by id, lowercased, the way OpenClaw compares model ids. */
23
+ const modelsById = new Map(buildKonduitProvider().models.map((model) => [model.id.trim().toLowerCase(), model]));
24
+ /**
25
+ * One manifest model as a model OpenClaw can route to, or nothing for an id
26
+ * konduit does not serve.
27
+ *
28
+ * OpenClaw reads a plugin's manifest catalog at resolution time only for
29
+ * plugins it ships itself; a plugin installed from ClawHub lives outside that
30
+ * set, so its models are listed but cannot be selected. This hook is the
31
+ * documented way out: the provider answers the lookup itself.
32
+ */
33
+ export function resolveKonduitRuntimeModel(params) {
34
+ const model = modelsById.get(params.modelId.trim().toLowerCase());
35
+ return model && toKonduitRuntimeModel(model, params.baseUrl);
36
+ }
37
+ /**
38
+ * One catalog row as a model OpenClaw can route to. A user who pointed
39
+ * models.providers.konduit at another gateway keeps that baseUrl.
40
+ */
41
+ export function toKonduitRuntimeModel(model, baseUrl) {
42
+ return {
43
+ ...structuredClone(model),
44
+ provider: PROVIDER_ID,
45
+ api: "openai-completions",
46
+ baseUrl: baseUrl?.trim() || KONDUIT_BASE_URL,
47
+ // The catalog type leaves name, cost, input and the caps optional; every
48
+ // row this plugin projects carries them, static or live.
49
+ };
50
+ }
51
+ /**
52
+ * konduit's own GET /v1/models rows as catalog entries — the same projection
53
+ * the manifest generator writes, run at discovery time.
54
+ *
55
+ * OpenClaw's generic mapper reads an OpenAI `/models` body, which carries an id
56
+ * and little else; konduit's carries the context window, the output cap, the
57
+ * capabilities and the price. Handing it the generic reader would list a
58
+ * deployment konduit added with guessed numbers, and would offer konduit's
59
+ * embedding deployment as something to chat with.
60
+ *
61
+ * A row this release cannot read keeps the entry the manifest already has for
62
+ * it, and a body with nothing readable in it leaves the manifest's list alone,
63
+ * because an empty catalog is how a provider disappears from the picker.
64
+ */
65
+ export function projectKonduitLiveModels(rows, fallbackModels) {
66
+ const knownById = new Map(fallbackModels.map((model) => [model.id, model]));
67
+ const projected = new Map();
68
+ for (const row of rows) {
69
+ try {
70
+ // mapCatalog applies the servable-chat predicate, so a retired, embedding
71
+ // or image deployment drops out here rather than in a second copy of it.
72
+ for (const model of mapCatalog([row]))
73
+ projected.set(model.id, model);
74
+ }
75
+ catch {
76
+ const id = typeof row?.id === "string" ? row.id : undefined;
77
+ const known = id === undefined ? undefined : knownById.get(id);
78
+ if (known)
79
+ projected.set(known.id, known);
80
+ }
81
+ }
82
+ if (projected.size === 0)
83
+ return [...fallbackModels];
84
+ return [...projected.values()].sort((left, right) => left.id.localeCompare(right.id));
85
+ }
86
+ /**
87
+ * What the entry hands OpenClaw's live discovery. Declared here, beside the
88
+ * projection it runs, so it is exercised by the same tests.
89
+ */
90
+ export const konduitLiveModelDiscovery = {
91
+ projectRows: (rows, fallback) => projectKonduitLiveModels(rows, fallback.models),
92
+ };
93
+ /**
94
+ * The deployment ids this release lists, for a message that has to name them.
95
+ * A copy: the caller must not be able to edit the manifest through it.
96
+ */
97
+ export function listKonduitModelIds() {
98
+ return [...modelsById.values()].map((model) => model.id);
99
+ }
100
+ /**
101
+ * The id konduit serves that a user most likely meant. Only one case is worth
102
+ * guessing: a deployment written without its `:variant`, which is the id konduit
103
+ * publishes for a model served in one precision and the spelling a user copies
104
+ * from somewhere else. Anything further from the truth gets the list instead.
105
+ */
106
+ export function suggestKonduitModelId(modelId) {
107
+ const wanted = modelId.trim().toLowerCase();
108
+ if (!wanted || wanted.includes(":"))
109
+ return;
110
+ return listKonduitModelIds().find((id) => id.toLowerCase().split(":")[0] === wanted);
111
+ }
@@ -0,0 +1,48 @@
1
+ // konduit's catalog at resolution time. The manifest answers the models a
2
+ // release knows; this module answers the ones konduit started serving since —
3
+ // so a deployment that shows up in the model list can also be selected, rather
4
+ // than listing and then failing with "Unknown model".
5
+ import { getCachedLiveCatalogValue } from "openclaw/plugin-sdk/provider-catalog-shared";
6
+ import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
7
+ import { fetchJson } from "openclaw/plugin-sdk/provider-usage";
8
+ import { PROVIDER_ID, projectKonduitLiveModels, toKonduitRuntimeModel } from "./catalog.js";
9
+ const LABEL = "konduit model catalog";
10
+ // A minute, the same window OpenClaw's own live discovery keeps: long enough
11
+ // that a busy session reads the catalog once, short enough that a deployment
12
+ // konduit adds is usable while the user is still looking for it.
13
+ const TTL_MS = 60_000;
14
+ const TIMEOUT_MS = 5_000;
15
+ /**
16
+ * The deployment konduit serves under this id, or nothing.
17
+ *
18
+ * Nothing is also the answer when konduit refuses the catalog or cannot be
19
+ * reached: a model lookup is not the place to surface that, and the request the
20
+ * user actually made reports it with its own status.
21
+ */
22
+ export async function resolveKonduitLiveModel(lookup) {
23
+ const baseUrl = lookup.baseUrl.trim().replace(/\/+$/, "");
24
+ if (!baseUrl || !lookup.apiKey)
25
+ return;
26
+ const wanted = lookup.modelId.trim().toLowerCase();
27
+ try {
28
+ const models = await getCachedLiveCatalogValue({
29
+ keyParts: [PROVIDER_ID, "live-models", baseUrl, lookup.apiKey],
30
+ ttlMs: TTL_MS,
31
+ load: async () => await readServableModels(baseUrl, lookup),
32
+ shouldCache: (models) => models.length > 0,
33
+ });
34
+ const model = models.find((candidate) => candidate.id.trim().toLowerCase() === wanted);
35
+ return model && toKonduitRuntimeModel(model, baseUrl);
36
+ }
37
+ catch {
38
+ return;
39
+ }
40
+ }
41
+ /** GET {baseUrl}/models, projected the way the manifest generator projects it. */
42
+ async function readServableModels(baseUrl, lookup) {
43
+ const response = await fetchJson(`${baseUrl}/models`, { headers: { Authorization: `Bearer ${lookup.apiKey}`, Accept: "application/json" } }, lookup.timeoutMs ?? TIMEOUT_MS, lookup.fetchFn ?? fetch);
44
+ if (!response.ok)
45
+ throw new Error(`${LABEL}: HTTP ${response.status}`);
46
+ const body = await readProviderJsonResponse(response, LABEL);
47
+ return projectKonduitLiveModels(Array.isArray(body.data) ? body.data : [], []);
48
+ }
package/dist/index.js CHANGED
@@ -2,7 +2,8 @@
2
2
  // API, plus the two hooks that put the organisation's balance and limits on the
3
3
  // provider card. Shape follows OpenClaw's bundled extensions/deepseek.
4
4
  import { defineSingleProviderPluginEntry, } from "openclaw/plugin-sdk/provider-entry";
5
- import { buildKonduitProvider, manifest, PROVIDER_ID } from "./catalog.js";
5
+ import { buildKonduitProvider, konduitLiveModelDiscovery, KONDUIT_BASE_URL, listKonduitModelIds, manifest, PROVIDER_ID, resolveKonduitRuntimeModel, suggestKonduitModelId, } from "./catalog.js";
6
+ import { resolveKonduitLiveModel } from "./discovery.js";
6
7
  import { fetchKonduitUsage, resolveBaseUrl } from "./usage.js";
7
8
  export default defineSingleProviderPluginEntry({
8
9
  id: PROVIDER_ID,
@@ -20,10 +21,50 @@ export default defineSingleProviderPluginEntry({
20
21
  // A user who configured models.providers.konduit.baseUrl keeps it.
21
22
  allowExplicitBaseUrl: true,
22
23
  // GET {baseUrl}/models: a deployment konduit adds appears without a plugin
23
- // release, at cost zero until the generator writes its price.
24
- liveModelDiscovery: true,
24
+ // release. konduit's catalog carries the context window, the output cap,
25
+ // the capabilities and the price, so the rows are read here rather than
26
+ // by OpenClaw's generic reader, which would only find an id.
27
+ liveModelDiscovery: konduitLiveModelDiscovery,
25
28
  discoveryMode: "strict",
26
29
  },
30
+ // OpenClaw reads a manifest catalog at model-resolution time only for the
31
+ // plugins it ships itself. Without this hook a ClawHub install lists its
32
+ // models and then fails to select one: "Unknown model: konduit/…".
33
+ resolveDynamicModel: ({ modelId, providerConfig }) => resolveKonduitRuntimeModel({ modelId, ...(providerConfig?.baseUrl ? { baseUrl: providerConfig.baseUrl } : {}) }),
34
+ // The async half of the same answer: a deployment konduit started serving
35
+ // after this release is in konduit's catalog and not in the manifest, and
36
+ // listing it without being able to select it is the bug one level up.
37
+ prepareDynamicModel: async ({ modelId, providerConfig, config, agentDir, workspaceDir, authProfileId }) => {
38
+ const baseUrl = providerConfig?.baseUrl;
39
+ const listed = resolveKonduitRuntimeModel({ modelId, ...(baseUrl ? { baseUrl } : {}) });
40
+ if (listed)
41
+ return listed;
42
+ // The key lives in OpenClaw's auth store; this is the SDK's way in.
43
+ const { resolveApiKeyForProvider } = await import("openclaw/plugin-sdk/provider-auth-runtime");
44
+ const apiKey = (await resolveApiKeyForProvider({
45
+ provider: PROVIDER_ID,
46
+ cfg: config,
47
+ ...(agentDir ? { agentDir } : {}),
48
+ ...(workspaceDir ? { workspaceDir } : {}),
49
+ ...(authProfileId ? { profileId: authProfileId, lockedProfile: true } : {}),
50
+ }))?.apiKey;
51
+ if (!apiKey)
52
+ return;
53
+ return await resolveKonduitLiveModel({
54
+ modelId,
55
+ baseUrl: baseUrl?.trim() || KONDUIT_BASE_URL,
56
+ apiKey,
57
+ });
58
+ },
59
+ // OpenClaw's generic advice for an unknown model is to register it under
60
+ // models.providers, which is not how this plugin is used: konduit publishes
61
+ // its own catalog, so the answer is what konduit serves.
62
+ buildUnknownModelHint: ({ modelId }) => {
63
+ const suggestion = suggestKonduitModelId(modelId);
64
+ const named = suggestion ? `konduit serves that model as "${suggestion}".` : `konduit serves no deployment called "${modelId}".`;
65
+ return `${named} Deployment ids are provider/model[:variant]; \`openclaw models list --provider konduit\` lists the ${listKonduitModelIds().length} this release knows, and konduit's own catalog is read at runtime for the rest.`;
66
+ },
67
+ buildMissingAuthMessage: () => 'Mint a konduit API key at https://console.konduit.eu, then run `openclaw models auth paste-api-key --provider konduit` — or set KONDUIT_API_KEY. A key with no scopes works; the provider card also needs `usage:read`.',
27
68
  // Both hooks are required for OpenClaw to poll this provider's usage at
28
69
  // all; a plugin with only fetchUsageSnapshot is not auto-discovered.
29
70
  resolveUsageAuth: async (ctx) => {
@@ -2,7 +2,7 @@
2
2
  "id": "konduit",
3
3
  "name": "konduit",
4
4
  "description": "European AI inference through konduit, with live balance and rate limits.",
5
- "version": "0.1.0",
5
+ "version": "0.2.0",
6
6
  "categories": [
7
7
  "models"
8
8
  ],
@@ -28,7 +28,7 @@
28
28
  {
29
29
  "id": "hetzner/qwen3.6-35b-a3b:fp8",
30
30
  "name": "Qwen3.6 35B A3B",
31
- "reasoning": false,
31
+ "reasoning": true,
32
32
  "input": [
33
33
  "text"
34
34
  ],
@@ -49,7 +49,7 @@
49
49
  {
50
50
  "id": "hetzner/qwen3.8-27b",
51
51
  "name": "Qwen3.8 27B",
52
- "reasoning": false,
52
+ "reasoning": true,
53
53
  "input": [
54
54
  "text"
55
55
  ],
@@ -70,7 +70,7 @@
70
70
  {
71
71
  "id": "scaleway/deepseek-v4-flash-0731",
72
72
  "name": "DeepSeek V4 Flash 0731",
73
- "reasoning": false,
73
+ "reasoning": true,
74
74
  "input": [
75
75
  "text"
76
76
  ],
@@ -91,7 +91,7 @@
91
91
  {
92
92
  "id": "scaleway/gemma-4-26b-a4b-it",
93
93
  "name": "Gemma 4 26B A4B IT",
94
- "reasoning": false,
94
+ "reasoning": true,
95
95
  "input": [
96
96
  "text"
97
97
  ],
@@ -112,7 +112,7 @@
112
112
  {
113
113
  "id": "scaleway/glm-5.2",
114
114
  "name": "GLM-5.2",
115
- "reasoning": false,
115
+ "reasoning": true,
116
116
  "input": [
117
117
  "text"
118
118
  ],
@@ -133,7 +133,7 @@
133
133
  {
134
134
  "id": "scaleway/gpt-oss-120b",
135
135
  "name": "GPT-OSS 120B",
136
- "reasoning": false,
136
+ "reasoning": true,
137
137
  "input": [
138
138
  "text"
139
139
  ],
@@ -280,7 +280,7 @@
280
280
  {
281
281
  "id": "scaleway/qwen3.5-397b-a17b",
282
282
  "name": "Qwen3.5 397B A17B",
283
- "reasoning": false,
283
+ "reasoning": true,
284
284
  "input": [
285
285
  "text"
286
286
  ],
@@ -301,7 +301,7 @@
301
301
  {
302
302
  "id": "scaleway/qwen3.6-35b-a3b",
303
303
  "name": "Qwen3.6 35B A3B",
304
- "reasoning": false,
304
+ "reasoning": true,
305
305
  "input": [
306
306
  "text"
307
307
  ],
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@konduiteu/openclaw",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "OpenClaw provider plugin for konduit: European inference, with live balance and rate limits on the provider card.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "https://github.com/schnaq/openclaw-konduit"
9
+ "url": "git+https://github.com/schnaq/openclaw-konduit.git"
10
10
  },
11
11
  "homepage": "https://konduit.eu",
12
12
  "engines": {