@frockbot/plugin-provider-ollama-cloud 0.0.0 → 0.1.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
@@ -1,3 +1,117 @@
1
1
  # @frockbot/plugin-provider-ollama-cloud
2
2
 
3
- Placeholder reserving this name. See https://github.com/timoconnellaus/frockbot.
3
+ The Ollama model provider Package. It contributes:
4
+
5
+ - a **User backend Contribution** (`./user`) that owns the `ollama-cloud-account`
6
+ Connection type: it admits `connection/*` commands durably, validates and
7
+ stores the API key, discovers the account's model catalog, refreshes that
8
+ catalog on a durable alarm, resolves models the catalog does not list, and
9
+ leases the credential for a Turn;
10
+ - a **runtime Contribution** (`./runtime`) that registers the `ollama-cloud`
11
+ provider behind the kernel's model interface, translating a normalized model
12
+ request onto Ollama's OpenAI-compatible `/v1/chat/completions` endpoint;
13
+ - the **`web_search` tool** (`./web-search`), a second Capability over the same
14
+ Connection.
15
+
16
+ ## The `ollama-cloud-web-search` Capability
17
+
18
+ `POST /api/web_search` authenticates with the _same_ key as `/api/chat` — see
19
+ `docs/research/ollama-cloud-auth.md` — and a credential is openable only by the
20
+ Package that owns its Connection. So the search transport lives here, beside the
21
+ model provider, rather than in a Package that would have to be handed a key it
22
+ does not own. `plugin-composio` is the precedent for a Connection-backed tool
23
+ Capability.
24
+
25
+ The _contract_ is not provider-specific. `@frockbot/plugin-web/contract` owns the
26
+ `WebSearchV1` interface, the result DTO, its decoder and the `web_search` tool
27
+ definition; this Package supplies transport and the credential, and a second
28
+ search provider satisfies the same contract with no change here and none in the
29
+ kernel.
30
+
31
+ | | |
32
+ | -------------- | ----------------------------------------------------------------------------------- |
33
+ | Capability | `ollama-cloud-web-search`, kind `tool`, `connectionTypes: ["ollama-cloud-account"]` |
34
+ | Endpoint | `POST {apiBaseUrl}/api/web_search` — the same resolved root chat uses |
35
+ | Input | `query` 1–400 chars, `max_results` 1–10 (default 5) |
36
+ | Response bound | 256 KiB, snippets trimmed to 1 000 characters |
37
+ | Durable result | `{"query", "results":[{"title","url","snippet"}]}` |
38
+ | Effect class | read-only, `idempotent: true` |
39
+ | Turn types | all four (manifest v4 `admission`) |
40
+
41
+ **Authority.** The tool mounts only for an enabled Assignment of
42
+ `ollama-cloud-web-search` bound to a ready Connection. There is no
43
+ unauthenticated fallback: a Bot without the Assignment is never offered the
44
+ tool. The key is leased per durable `effectId`, opened inside this Package, used
45
+ and settled, so it never reaches a tool argument, a tool result, or the event
46
+ log.
47
+
48
+ ## The `api-base-url` Connection setting
49
+
50
+ A Connection is not pinned to `https://ollama.com`. Its User may point it at any
51
+ Ollama-compatible endpoint — a self-hosted gateway, a colleague's GPU box, or a
52
+ local Ollama server at `http://127.0.0.1:11434`.
53
+
54
+ The endpoint is a **Connection-scoped setting**: it belongs to one Connection,
55
+ not to the User or the Bot, so a User may hold a Cloud Connection and a local
56
+ Connection side by side and assign either to a Bot.
57
+
58
+ The manifest declares it in `configuration.settings` as `api-base-url` — the
59
+ kernel requires lowercase kebab-case Contribution ids — and it is carried on the
60
+ Connection settings bag under the key **`api-base-url`**:
61
+
62
+ ```json
63
+ {
64
+ "id": "api-base-url",
65
+ "schemaVersion": 1,
66
+ "scopes": ["connection"],
67
+ "schema": { "type": "string", "minLength": 1, "maxLength": 2048 }
68
+ }
69
+ ```
70
+
71
+ ### Setting it
72
+
73
+ Supply it when the Connection is created, on the
74
+ `connection/create-api-key` command:
75
+
76
+ ```jsonc
77
+ POST /api/connections
78
+ {
79
+ "schemaVersion": 1,
80
+ "type": "connection/create-api-key",
81
+ "commandId": "<idempotency key>",
82
+ "packageId": "provider-ollama-cloud",
83
+ "connectionTypeId": "ollama-cloud-account",
84
+ "label": "Workstation Ollama",
85
+ "apiKey": "<key>",
86
+ "settings": { "api-base-url": "http://127.0.0.1:11434" }
87
+ }
88
+ ```
89
+
90
+ Omit `settings` entirely for Ollama Cloud. In the WebUI the same value is the
91
+ optional **API base URL** field on the Connect form; leaving it empty sends no
92
+ `settings` field at all.
93
+
94
+ The value is the **endpoint root**, with no `/api` or `/v1` path segment: the
95
+ Package appends `/api/...` for catalog and probe calls and `/v1` for chat
96
+ completions. It must be an absolute `http:` or `https:` URL carrying no
97
+ credentials, query, or fragment; a trailing slash is stripped. Anything else is
98
+ refused at the seam, and the command fails with a visible reason on the
99
+ Connection rather than silently falling back to the default.
100
+
101
+ Every provider call for a Connection uses that Connection's endpoint: the
102
+ creation catalog read, the key-validation probe, the periodic catalog refresh,
103
+ exact model resolution, and chat completions.
104
+
105
+ The setting is durable state on the Connection and is visible in
106
+ `GET /api/settings` under the Connection's `settings` bag.
107
+
108
+ ### The key is still proven
109
+
110
+ Pointing a Connection elsewhere does not relax key validation. Measured against
111
+ `https://ollama.com` and recorded in `docs/research/ollama-cloud-auth.md`,
112
+ `GET /api/tags`, `GET /v1/models`, and `POST /api/show` all answer 200 for a
113
+ valid key, a garbage key, and no key at all — so no catalog read can validate a
114
+ key. `POST /api/chat` does authenticate. Creating or rotating a key therefore
115
+ runs a real one-token inference probe against **whatever endpoint the Connection
116
+ is configured with**, and a Connection whose endpoint rejects the key ends
117
+ `failed` with the provider's reason, never `ready`.
package/frockbot.json ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "schemaVersion": 4,
3
+ "id": "provider-ollama-cloud",
4
+ "displayName": "Ollama Cloud",
5
+ "version": "0.0.1",
6
+ "compatibility": { "frockbot": ">=0.0.1" },
7
+ "dependencies": {
8
+ "credentials": ">=0.0.1",
9
+ "settings": ">=0.0.1",
10
+ "web": ">=0.0.1"
11
+ },
12
+ "contributions": {
13
+ "backend": [{ "entry": "./user", "host": "user" }],
14
+ "runtime": { "entry": "./runtime" }
15
+ },
16
+ "permissions": [
17
+ "connections:manage",
18
+ "models:invoke",
19
+ "tools:execute",
20
+ "network:ollama-cloud"
21
+ ],
22
+ "configuration": {
23
+ "settings": [
24
+ {
25
+ "id": "web-search-max-results",
26
+ "schemaVersion": 1,
27
+ "scopes": ["user"],
28
+ "schema": {
29
+ "type": "integer",
30
+ "title": "Web search results",
31
+ "description": "The most results web_search returns, whatever the model asks for. Absent means the tool's own default of 5.",
32
+ "minimum": 1,
33
+ "maximum": 10
34
+ }
35
+ }
36
+ ],
37
+ "connectionTypes": [
38
+ {
39
+ "id": "ollama-cloud-account",
40
+ "displayName": "Ollama Cloud account",
41
+ "allowMultiple": true,
42
+ "authorization": { "kind": "api-key", "driverId": "ollama-cloud" },
43
+ "capabilities": ["ollama-cloud-models", "ollama-cloud-web-search"],
44
+ "settings": [
45
+ {
46
+ "id": "api-base-url",
47
+ "schemaVersion": 1,
48
+ "schema": {
49
+ "type": "string",
50
+ "title": "API base URL",
51
+ "description": "Root URL of the Ollama-compatible endpoint, without a trailing slash. Absent means https://ollama.com; use http://127.0.0.1:11434 for a local Ollama server.",
52
+ "minLength": 1,
53
+ "maxLength": 2048
54
+ }
55
+ }
56
+ ]
57
+ }
58
+ ],
59
+ "capabilities": [
60
+ {
61
+ "id": "ollama-cloud-models",
62
+ "kind": "model",
63
+ "connectionTypes": ["ollama-cloud-account"],
64
+ "admission": {
65
+ "turnTypes": ["chat", "automation", "subagent"]
66
+ }
67
+ },
68
+ {
69
+ "id": "ollama-cloud-web-search",
70
+ "kind": "tool",
71
+ "connectionTypes": ["ollama-cloud-account"],
72
+ "admission": {
73
+ "turnTypes": ["chat", "automation", "subagent"],
74
+ "subagentRoles": ["executor"]
75
+ }
76
+ }
77
+ ]
78
+ }
79
+ }
package/package.json CHANGED
@@ -1,14 +1,47 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-provider-ollama-cloud",
3
- "version": "0.0.0",
4
- "description": "Placeholder reserving this name for trusted publishing. Superseded by the first release.",
5
- "license": "UNLICENSED",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./runtime": "./src/runtime.ts",
9
+ "./user": "./src/user.ts",
10
+ "./manifest": "./src/manifest.ts",
11
+ "./frockbot.json": "./frockbot.json",
12
+ "./package.json": "./package.json",
13
+ "./web-search": "./src/web-search.ts"
14
+ },
15
+ "frockbot": {
16
+ "manifest": "./frockbot.json"
17
+ },
18
+ "scripts": {
19
+ "test": "bun test src",
20
+ "typecheck": "tsc --noEmit -p tsconfig.json"
21
+ },
22
+ "dependencies": {
23
+ "@frockbot/configuration-core": "0.1.0",
24
+ "@frockbot/connection-core": "0.1.0",
25
+ "@frockbot/kernel-agent-loop": "0.1.0",
26
+ "@frockbot/kernel-contracts": "0.1.0",
27
+ "@frockbot/plugin-credentials": "0.1.0",
28
+ "@frockbot/plugin-models": "0.1.0",
29
+ "@frockbot/plugin-settings": "0.1.0",
30
+ "@frockbot/plugin-web": "0.1.0",
31
+ "@frockbot/provider-openai-compatible": "0.1.0",
32
+ "cordis": "4.0.0-rc.8"
33
+ },
34
+ "devDependencies": {
35
+ "@frockbot/plugin-tools": "0.1.0",
36
+ "@types/bun": "1.3.6",
37
+ "typescript": "^7.0.2"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
6
42
  "repository": {
7
43
  "type": "git",
8
44
  "url": "git+https://github.com/timoconnellaus/frockbot.git",
9
45
  "directory": "packages/plugin-provider-ollama-cloud"
10
- },
11
- "publishConfig": {
12
- "access": "public"
13
46
  }
14
47
  }
@@ -0,0 +1,140 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { OllamaCloudClient } from "./client.js";
3
+
4
+ describe("Ollama Cloud client", () => {
5
+ test("discovers account models and normalizes provider capabilities", async () => {
6
+ const requests: Request[] = [];
7
+ const client = new OllamaCloudClient({
8
+ fetch: async (input, init) => {
9
+ const request = new Request(input, init);
10
+ requests.push(request);
11
+ if (request.url.endsWith("/tags")) {
12
+ return Response.json({ models: [{ model: "glm-5.3-flash:cloud" }] });
13
+ }
14
+ return Response.json({
15
+ capabilities: ["completion", "tools", "thinking"],
16
+ model_info: { "glm.context_length": 1_048_576 },
17
+ });
18
+ },
19
+ });
20
+
21
+ expect(await client.listModels("account-key")).toEqual([
22
+ {
23
+ providerModelId: "glm-5.3-flash:cloud",
24
+ displayName: "glm-5.3-flash",
25
+ contextWindow: 1_048_576,
26
+ capabilities: { tools: true, vision: false, reasoning: true },
27
+ source: "discovered",
28
+ },
29
+ ]);
30
+ expect(requests).toHaveLength(2);
31
+ expect(
32
+ requests.every(
33
+ (request) =>
34
+ request.headers.get("authorization") === "Bearer account-key",
35
+ ),
36
+ ).toBe(true);
37
+ });
38
+
39
+ test("rejects provider model metadata outside the catalog contract", async () => {
40
+ let requests = 0;
41
+ const client = new OllamaCloudClient({
42
+ fetch: (input) => {
43
+ requests += 1;
44
+ return Promise.resolve(
45
+ String(input).endsWith("/tags")
46
+ ? Response.json({ models: [{ model: "x".repeat(257) }] })
47
+ : Response.json({ capabilities: [] }),
48
+ );
49
+ },
50
+ });
51
+
52
+ await expect(client.listModels("account-key")).rejects.toThrow(
53
+ "Ollama Cloud model id is invalid",
54
+ );
55
+ expect(requests).toBe(1);
56
+ });
57
+
58
+ test("truncates oversized advisory catalogs before detail fanout", async () => {
59
+ let requests = 0;
60
+ const client = new OllamaCloudClient({
61
+ fetch: (input) => {
62
+ requests += 1;
63
+ return Promise.resolve(
64
+ String(input).endsWith("/tags")
65
+ ? Response.json({
66
+ models: Array.from({ length: 101 }, (_, index) => ({
67
+ model: `model-${index}:cloud`,
68
+ })),
69
+ })
70
+ : Response.json({ capabilities: [] }),
71
+ );
72
+ },
73
+ });
74
+
75
+ const models = await client.listModels("account-key");
76
+
77
+ expect(models).toHaveLength(100);
78
+ expect(models.at(-1)?.providerModelId).toBe("model-99:cloud");
79
+ expect(requests).toBe(101);
80
+ });
81
+
82
+ test("limits concurrent model detail lookups", async () => {
83
+ const fourStarted = Promise.withResolvers<void>();
84
+ const release = Promise.withResolvers<void>();
85
+ let active = 0;
86
+ let maximumActive = 0;
87
+ let detailRequests = 0;
88
+ const client = new OllamaCloudClient({
89
+ fetch: async (input) => {
90
+ if (String(input).endsWith("/tags")) {
91
+ return Response.json({
92
+ models: Array.from({ length: 8 }, (_, index) => ({
93
+ model: `model-${index}:cloud`,
94
+ })),
95
+ });
96
+ }
97
+ detailRequests += 1;
98
+ active += 1;
99
+ maximumActive = Math.max(maximumActive, active);
100
+ if (active === 4) fourStarted.resolve();
101
+ await release.promise;
102
+ active -= 1;
103
+ return Response.json({ capabilities: [] });
104
+ },
105
+ });
106
+
107
+ const models = client.listModels("account-key");
108
+ await fourStarted.promise;
109
+ await Promise.resolve();
110
+ expect(detailRequests).toBe(4);
111
+ release.resolve();
112
+
113
+ expect(await models).toHaveLength(8);
114
+ expect(maximumActive).toBe(4);
115
+ });
116
+
117
+ test("rejects oversized provider responses", async () => {
118
+ const client = new OllamaCloudClient({
119
+ fetch: () =>
120
+ Promise.resolve(Response.json({ padding: "x".repeat(512 * 1024) })),
121
+ });
122
+
123
+ await expect(client.listModels("account-key")).rejects.toThrow(
124
+ "Ollama Cloud response is too large",
125
+ );
126
+ });
127
+
128
+ test("does not expose provider response bodies in errors", async () => {
129
+ const client = new OllamaCloudClient({
130
+ fetch: () =>
131
+ Promise.resolve(
132
+ new Response("credential=do-not-leak", { status: 401 }),
133
+ ),
134
+ });
135
+
136
+ await expect(client.listModels("secret")).rejects.toThrow(
137
+ "Ollama Cloud request failed (401)",
138
+ );
139
+ });
140
+ });