@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 +115 -1
- package/frockbot.json +79 -0
- package/package.json +39 -6
- package/src/client.test.ts +140 -0
- package/src/client.ts +357 -0
- package/src/endpoint.test.ts +412 -0
- package/src/index.ts +2 -0
- package/src/manifest.ts +3 -0
- package/src/runtime.test.ts +518 -0
- package/src/runtime.ts +193 -0
- package/src/user.test.ts +2230 -0
- package/src/user.ts +2444 -0
- package/src/web-search.test.ts +383 -0
- package/src/web-search.ts +299 -0
- package/tsconfig.json +15 -0
package/README.md
CHANGED
|
@@ -1,3 +1,117 @@
|
|
|
1
1
|
# @frockbot/plugin-provider-ollama-cloud
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
});
|