@morlay/dsh-web-search-ollama 0.0.1-alpha.2
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/LICENSE +21 -0
- package/README.md +100 -0
- package/dist/index.d.mts +68 -0
- package/dist/index.mjs +147 -0
- package/package.json +39 -0
- package/src/index.ts +64 -0
- package/src/provider.ts +178 -0
- package/src/types.ts +29 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 morlay
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# @morlay/dsh-web-search-ollama
|
|
2
|
+
|
|
3
|
+
往 `ctx.web` 注册一个搜索后端:`web_search` 的检索走 Ollama 自己的
|
|
4
|
+
`POST https://ollama.com/api/web_search`,用与 `llm-pi-ai` 的 ollama route 同一个
|
|
5
|
+
`OLLAMA_API_KEY`。
|
|
6
|
+
|
|
7
|
+
## 为什么不是"复用上游的 messages 后端"
|
|
8
|
+
|
|
9
|
+
上游 `@deepseek-ai/dsh-web-search-deepseek` 走 Anthropic Messages 的
|
|
10
|
+
`web_search_20250305` server tool,看起来只要把 `baseURL` 指到 `https://ollama.com/v1` 就能白拿
|
|
11
|
+
Ollama 的搜索。**实测不行**:真发一次
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
curl https://ollama.com/v1/messages -H "Authorization: Bearer $OLLAMA_API_KEY" \
|
|
15
|
+
-H "anthropic-version: 2023-06-01" -H "content-type: application/json" \
|
|
16
|
+
-d '{"model":"deepseek-v4.1-flash","max_tokens":4096,
|
|
17
|
+
"messages":[{"role":"user","content":[{"type":"text","text":"Perform a web search for the query: cordis plugin framework"}]}],
|
|
18
|
+
"tools":[{"type":"web_search_20250305","name":"web_search","max_uses":5}]}'
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
回的 `content` 块只有 `thinking` + `tool_use`(`stop_reason: tool_use`)——**Ollama 把这个 server
|
|
22
|
+
tool 降级成普通客户端工具,它不自己执行搜索**,要求调用方执行后再回传 `tool_result`。而那个上游
|
|
23
|
+
provider 只认服务端自执行后返回的 `web_search_tool_result` 块,找不到就报
|
|
24
|
+
`WEB_PROVIDER_ERROR`("returned no web_search_tool_result blocks")。Ollama 官方文档那句
|
|
25
|
+
"hosted web search are not fully supported"([docs.ollama.com/api/anthropic-compatibility](https://docs.ollama.com/api/anthropic-compatibility))
|
|
26
|
+
就是这个意思,[issue #17283](https://github.com/ollama/ollama/issues/17283) 里说的
|
|
27
|
+
`WebSearchAnthropicWriter` 拦截只发生在本地 server 的 cloud 模型路径上。
|
|
28
|
+
|
|
29
|
+
Ollama 真正对外提供搜索的是它自己的 REST 接口——同一个 key、一次 HTTP、返回扁平
|
|
30
|
+
`{results:[{title,url,content}]}`:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
curl https://ollama.com/api/web_search -H "Authorization: Bearer $OLLAMA_API_KEY" \
|
|
34
|
+
-H "content-type: application/json" -d '{"query":"cordis plugin framework"}'
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
本包就是把这一条接进 `ctx.web`,代价只有一个 ~200 行的 provider。
|
|
38
|
+
|
|
39
|
+
## 配置
|
|
40
|
+
|
|
41
|
+
| 字段 | 默认 | 含义 |
|
|
42
|
+
| ------------ | ------------------------ | ---------------------------------------------------------- |
|
|
43
|
+
| `apiKey` | — | 字面 key;优先用下面那个引用,别把密钥写进配置文件 |
|
|
44
|
+
| `apiKeyEnv` | `OLLAMA_API_KEY` | 每次搜索解析一次的凭证引用(凭证服务,缺席时回退启动环境) |
|
|
45
|
+
| `baseURL` | `https://ollama.com` | 端点根;`/api/web_search` 由 provider 拼 |
|
|
46
|
+
| `maxResults` | —(Ollama 自己的默认 5) | 请求没带 `maxResults` 时的默认结果数 |
|
|
47
|
+
|
|
48
|
+
## 行为
|
|
49
|
+
|
|
50
|
+
- **结果映射**:每条 `results[]` 给一个 source;`title` / `content` 空串视为缺失
|
|
51
|
+
(`content` → `snippet`),**没有 `url` 的条目直接丢掉**(seam 的 source 必须有 url)。
|
|
52
|
+
Ollama 不返回生成式答案,所以结果里没有 `content` 字段。
|
|
53
|
+
- **`max_results` 上限 10**:Ollama 的硬上限;请求与配置给出的更大值在发出前压到 10。
|
|
54
|
+
- **错误码**:HTTP 失败 → `WEB_PROVIDER_ERROR`(优先用响应体里的 `error` 文案,非 JSON 体退回
|
|
55
|
+
`Ollama API error (HTTP <status>)`);网络失败 → `WEB_PROVIDER_ERROR`;取消 → `WEB_ABORTED`
|
|
56
|
+
(任何阶段,含读体);取不到 key → `WEB_PROVIDER_CREDENTIAL_MISSING` 且不发请求。
|
|
57
|
+
- **可用性检查不联网**:`available()` 只看 key 有没有来源、`baseURL` 能否解析。
|
|
58
|
+
|
|
59
|
+
## 验证
|
|
60
|
+
|
|
61
|
+
`src/__tests__/provider.spec.ts` 是 mock 单测,默认 `just test` 覆盖它。链路是否真的成立由
|
|
62
|
+
[`e2e/provider.e2e.spec.ts`](./e2e/provider.e2e.spec.ts) 打真端点守——**它刻意放在 `src/__tests__/` 之外**:
|
|
63
|
+
根 `vitest.config.ts` 的 include 收不到它,默认全量测试不会去打外网,只由显式命令触发(`OLLAMA_API_KEY`
|
|
64
|
+
从启动环境注入,缺席则整体跳过):
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
mise exec -- pnpm --filter @morlay/dsh-web-search-ollama run test:e2e
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
两条都过才算通过:provider 直连拿回可引用 source,以及 provider 装进 `ctx.web` 后选中它再搜一次。别用
|
|
71
|
+
mock 全绿替代它——这个包的第一版就是"配置指到 Ollama 的 messages 端点"、mock 也全绿,真调用却是错的。
|
|
72
|
+
触发方式、门控与判据的 home 是[包层规范](./.agents/standards/how-to-verify.md)。
|
|
73
|
+
|
|
74
|
+
## 装配
|
|
75
|
+
|
|
76
|
+
注册行由 [`@morlay/dsh-preset`](../../preset/dsh-preset/README.md) 的 bundle patch 插在 **host 层**,
|
|
77
|
+
并把基础 bundle 的 `web` 行 `searchProvider` 切到 `ollama`:
|
|
78
|
+
|
|
79
|
+
```yaml
|
|
80
|
+
- id: web
|
|
81
|
+
config:
|
|
82
|
+
searchProvider: ollama
|
|
83
|
+
fetchProvider: http
|
|
84
|
+
- insert:
|
|
85
|
+
- id: web-search-ollama
|
|
86
|
+
name: "@morlay/dsh-web-search-ollama"
|
|
87
|
+
config:
|
|
88
|
+
apiKeyEnv: OLLAMA_API_KEY
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
注册行不能放进 preset:preset 是每个 agent 各挂一份,同一个 provider id 注册两次会撞
|
|
92
|
+
`WEB_DUPLICATE_PROVIDER`;选择权在 `web` 行的 `searchProvider`,而那一行本来就是 host 行。
|
|
93
|
+
|
|
94
|
+
## 边界
|
|
95
|
+
|
|
96
|
+
- **只注册搜索后端**:`web_fetch` 仍走基础 bundle 的 `web-fetch-http`,本包不碰抓取。
|
|
97
|
+
- **一次只选一个后端**:`ctx.web` 的 `searchProvider` 是单选,默认 `ollama`;
|
|
98
|
+
`deepseek-official` 后端仍由基础 bundle 注册着,改写那一行即可切回(但它需要 `DEEPSEEK_API_KEY`)。
|
|
99
|
+
- **不做设置页 section**:配置面就是那一行 patch(见
|
|
100
|
+
[dsh-preset 的设计记录](../../preset/dsh-preset/.agents/designs/20260917-host层部署配置.md))。
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import z from "@deepseek-ai/schemastery";
|
|
2
|
+
import { WebSearchProvider, WebSearchRequest, WebSearchResult, WebSearchSource } from "@deepseek-ai/dsh-web";
|
|
3
|
+
import { Context } from "@deepseek-ai/cordis";
|
|
4
|
+
//#region src/types.d.ts
|
|
5
|
+
/** `results[]` 的一条;字段都可能缺失或为空串。 */
|
|
6
|
+
interface OllamaSearchResult {
|
|
7
|
+
title?: string | null;
|
|
8
|
+
url?: string | null;
|
|
9
|
+
content?: string | null;
|
|
10
|
+
}
|
|
11
|
+
/** 成功响应信封。 */
|
|
12
|
+
interface OllamaSearchResponse {
|
|
13
|
+
results?: OllamaSearchResult[];
|
|
14
|
+
}
|
|
15
|
+
//#endregion
|
|
16
|
+
//#region src/provider.d.ts
|
|
17
|
+
/** 注册进 `ctx.web` 的 id;与 llm route 同名,配置里看到的就是同一个"ollama"。 */
|
|
18
|
+
declare const OLLAMA_PROVIDER_ID = "ollama";
|
|
19
|
+
/** 默认端点;`/api/web_search` 是操作。 */
|
|
20
|
+
declare const OLLAMA_DEFAULT_BASE_URL = "https://ollama.com";
|
|
21
|
+
/** Ollama 服务端自己的默认结果数。 */
|
|
22
|
+
declare const OLLAMA_DEFAULT_MAX_RESULTS = 5;
|
|
23
|
+
/** Ollama `max_results` 的硬上限;超过它的值不发给服务端。 */
|
|
24
|
+
declare const OLLAMA_MAX_RESULTS_LIMIT = 10;
|
|
25
|
+
interface OllamaSearchProviderOptions {
|
|
26
|
+
/** 字面 key;给了就压过 {@link resolveApiKey}。 */
|
|
27
|
+
apiKey?: string;
|
|
28
|
+
/** 运行期取 key 的入口(凭证服务 / 环境);返回空表示取不到。 */
|
|
29
|
+
resolveApiKey?: () => Promise<string | undefined>;
|
|
30
|
+
/** 取不到 key 时报出来的引用名,便于用户知道去哪配。 */
|
|
31
|
+
apiKeyEnv: string;
|
|
32
|
+
baseURL: string;
|
|
33
|
+
/** 请求没给 `maxResults` 时用的默认值;省略表示让 Ollama 用它自己的默认。 */
|
|
34
|
+
maxResults?: number;
|
|
35
|
+
}
|
|
36
|
+
/** 一条 Ollama 结果 → 一个 source;没有 url 就丢掉(seam 的 source 必须有 url)。 */
|
|
37
|
+
declare function mapOllamaResult(result: OllamaSearchResult): WebSearchSource | undefined;
|
|
38
|
+
/** 响应信封 → 归一化结果;形状不对就抛(由调用方包成 `WEB_PROVIDER_ERROR`)。 */
|
|
39
|
+
declare function mapOllamaResponse(response: OllamaSearchResponse): WebSearchResult;
|
|
40
|
+
/** Ollama 的搜索后端;HTTP 重定向按 `WEB_PROVIDER_ERROR` 失败。 */
|
|
41
|
+
declare class OllamaSearchProvider implements WebSearchProvider {
|
|
42
|
+
private readonly options;
|
|
43
|
+
readonly id = "ollama";
|
|
44
|
+
constructor(options: OllamaSearchProviderOptions);
|
|
45
|
+
available(): boolean;
|
|
46
|
+
search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>;
|
|
47
|
+
/** 一次操作一份 key,不留在 provider 上;没有可用的 key 就点名引用,而不是发无授权请求。 */
|
|
48
|
+
private apiKey;
|
|
49
|
+
}
|
|
50
|
+
//#endregion
|
|
51
|
+
//#region src/index.d.ts
|
|
52
|
+
declare const name = "web-search-ollama";
|
|
53
|
+
/** 只往 `ctx.web` 注册后端,不发布服务。 */
|
|
54
|
+
declare const inject: string[];
|
|
55
|
+
interface Config {
|
|
56
|
+
/** 字面 key;优先用 `apiKeyEnv` 走凭证服务,别把密钥写进配置文件。 */
|
|
57
|
+
apiKey?: string;
|
|
58
|
+
/** 每次搜索解析一次的凭证引用;缺省 `OLLAMA_API_KEY`。 */
|
|
59
|
+
apiKeyEnv?: string;
|
|
60
|
+
/** 端点根;`/api/web_search` 由 provider 拼。缺省 ollama.com。 */
|
|
61
|
+
baseURL?: string;
|
|
62
|
+
/** 请求没带 `maxResults` 时的默认结果数;省略表示让 Ollama 用自己的默认。 */
|
|
63
|
+
maxResults?: number;
|
|
64
|
+
}
|
|
65
|
+
declare const Config: z<Config>;
|
|
66
|
+
declare function apply(ctx: Context, config: Config): void;
|
|
67
|
+
//#endregion
|
|
68
|
+
export { Config, OLLAMA_DEFAULT_BASE_URL, OLLAMA_DEFAULT_MAX_RESULTS, OLLAMA_MAX_RESULTS_LIMIT, OLLAMA_PROVIDER_ID, OllamaSearchProvider, type OllamaSearchProviderOptions, apply, inject, mapOllamaResponse, mapOllamaResult, name };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
2
|
+
import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
|
|
3
|
+
import z from "@deepseek-ai/schemastery";
|
|
4
|
+
import { WebError } from "@deepseek-ai/dsh-web";
|
|
5
|
+
//#region src/provider.ts
|
|
6
|
+
/** 注册进 `ctx.web` 的 id;与 llm route 同名,配置里看到的就是同一个"ollama"。 */
|
|
7
|
+
const OLLAMA_PROVIDER_ID = "ollama";
|
|
8
|
+
/** 默认端点;`/api/web_search` 是操作。 */
|
|
9
|
+
const OLLAMA_DEFAULT_BASE_URL = "https://ollama.com";
|
|
10
|
+
/** Ollama 服务端自己的默认结果数。 */
|
|
11
|
+
const OLLAMA_DEFAULT_MAX_RESULTS = 5;
|
|
12
|
+
/** Ollama `max_results` 的硬上限;超过它的值不发给服务端。 */
|
|
13
|
+
const OLLAMA_MAX_RESULTS_LIMIT = 10;
|
|
14
|
+
/** 每次请求带的归属头。 */
|
|
15
|
+
const USER_AGENT = "morlay-dsh-plugin/0.0.1";
|
|
16
|
+
/** 一条 Ollama 结果 → 一个 source;没有 url 就丢掉(seam 的 source 必须有 url)。 */
|
|
17
|
+
function mapOllamaResult(result) {
|
|
18
|
+
const url = result.url;
|
|
19
|
+
if (typeof url !== "string" || url.length === 0) return void 0;
|
|
20
|
+
const title = typeof result.title === "string" && result.title.length > 0 ? result.title : void 0;
|
|
21
|
+
const snippet = typeof result.content === "string" && result.content.length > 0 ? result.content : void 0;
|
|
22
|
+
return {
|
|
23
|
+
url,
|
|
24
|
+
...title === void 0 ? {} : { title },
|
|
25
|
+
...snippet === void 0 ? {} : { snippet }
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** 响应信封 → 归一化结果;形状不对就抛(由调用方包成 `WEB_PROVIDER_ERROR`)。 */
|
|
29
|
+
function mapOllamaResponse(response) {
|
|
30
|
+
const results = response.results;
|
|
31
|
+
if (results !== void 0 && !Array.isArray(results)) throw new TypeError("Ollama returned a non-array `results` field");
|
|
32
|
+
return {
|
|
33
|
+
sources: (results ?? []).map((result) => mapOllamaResult(result)).filter((source) => source !== void 0),
|
|
34
|
+
truncated: false
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** Ollama 的搜索后端;HTTP 重定向按 `WEB_PROVIDER_ERROR` 失败。 */
|
|
38
|
+
var OllamaSearchProvider = class {
|
|
39
|
+
options;
|
|
40
|
+
id = OLLAMA_PROVIDER_ID;
|
|
41
|
+
constructor(options) {
|
|
42
|
+
this.options = options;
|
|
43
|
+
}
|
|
44
|
+
available() {
|
|
45
|
+
const { apiKey, resolveApiKey, baseURL, maxResults } = this.options;
|
|
46
|
+
return (apiKey !== void 0 && apiKey.length > 0 || resolveApiKey !== void 0) && URL.canParse(baseURL) && (maxResults === void 0 || isPositiveInteger(maxResults));
|
|
47
|
+
}
|
|
48
|
+
async search(request, signal) {
|
|
49
|
+
const apiKey = await this.apiKey();
|
|
50
|
+
const maxResults = clampMaxResults(request.maxResults ?? this.options.maxResults);
|
|
51
|
+
let response;
|
|
52
|
+
try {
|
|
53
|
+
response = await fetch(`${this.options.baseURL}/api/web_search`, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
redirect: "error",
|
|
56
|
+
headers: {
|
|
57
|
+
authorization: `Bearer ${apiKey}`,
|
|
58
|
+
"content-type": "application/json",
|
|
59
|
+
accept: "application/json",
|
|
60
|
+
"user-agent": USER_AGENT
|
|
61
|
+
},
|
|
62
|
+
body: JSON.stringify({
|
|
63
|
+
query: request.query,
|
|
64
|
+
...maxResults === void 0 ? {} : { max_results: maxResults }
|
|
65
|
+
}),
|
|
66
|
+
...signal === void 0 ? {} : { signal }
|
|
67
|
+
});
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (isAbortError(error)) throw searchAborted(error);
|
|
70
|
+
throw new WebError(`Ollama search request failed: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
|
|
71
|
+
}
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
let message = `Ollama API error (HTTP ${response.status})`;
|
|
74
|
+
try {
|
|
75
|
+
const detail = (await response.json()).error;
|
|
76
|
+
if (typeof detail === "string" && detail.length > 0) message = detail;
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (isAbortError(error)) throw searchAborted(error);
|
|
79
|
+
}
|
|
80
|
+
throw new WebError(message, "WEB_PROVIDER_ERROR");
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
return mapOllamaResponse(await response.json());
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (isAbortError(error)) throw searchAborted(error);
|
|
86
|
+
throw new WebError(`Ollama returned an unprocessable response body: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** 一次操作一份 key,不留在 provider 上;没有可用的 key 就点名引用,而不是发无授权请求。 */
|
|
90
|
+
async apiKey() {
|
|
91
|
+
const literal = this.options.apiKey;
|
|
92
|
+
if (literal !== void 0 && literal.length > 0) return literal;
|
|
93
|
+
let resolved;
|
|
94
|
+
try {
|
|
95
|
+
resolved = await this.options.resolveApiKey?.();
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (isAbortError(error)) throw searchAborted(error);
|
|
98
|
+
throw new WebError(`Ollama search credential resolution failed: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
|
|
99
|
+
}
|
|
100
|
+
if (resolved !== void 0 && resolved.length > 0) return resolved;
|
|
101
|
+
throw new WebError(`Ollama search has no API key for "${this.options.apiKeyEnv}"; store it through the credentials service (the web Models page writes it), export it in the launching environment, or set a literal "apiKey" in the web-search-ollama config`, "WEB_PROVIDER_CREDENTIAL_MISSING");
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
function clampMaxResults(maxResults) {
|
|
105
|
+
if (maxResults === void 0) return void 0;
|
|
106
|
+
return Math.min(maxResults, 10);
|
|
107
|
+
}
|
|
108
|
+
function isPositiveInteger(value) {
|
|
109
|
+
return Number.isInteger(value) && value > 0;
|
|
110
|
+
}
|
|
111
|
+
function isAbortError(error) {
|
|
112
|
+
return error instanceof DOMException && error.name === "AbortError";
|
|
113
|
+
}
|
|
114
|
+
function searchAborted(cause) {
|
|
115
|
+
return new WebError("Ollama search aborted", "WEB_ABORTED", { cause });
|
|
116
|
+
}
|
|
117
|
+
//#endregion
|
|
118
|
+
//#region src/index.ts
|
|
119
|
+
const name = "web-search-ollama";
|
|
120
|
+
/** 只往 `ctx.web` 注册后端,不发布服务。 */
|
|
121
|
+
const inject = ["web"];
|
|
122
|
+
/** 与 `llm-pi-ai` 的 ollama route 共用同一个 key 引用。 */
|
|
123
|
+
const DEFAULT_API_KEY_ENV = "OLLAMA_API_KEY";
|
|
124
|
+
const Config = z.object({
|
|
125
|
+
apiKey: z.string().role("secret"),
|
|
126
|
+
apiKeyEnv: z.string().role("credential-ref"),
|
|
127
|
+
baseURL: z.string(),
|
|
128
|
+
maxResults: z.number().step(1).min(1)
|
|
129
|
+
});
|
|
130
|
+
function apply(ctx, config) {
|
|
131
|
+
const apiKeyEnv = credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV);
|
|
132
|
+
const literal = config.apiKey !== void 0 && config.apiKey.length > 0 ? config.apiKey : void 0;
|
|
133
|
+
ctx.web.registerSearchProvider(new OllamaSearchProvider({
|
|
134
|
+
...literal === void 0 ? {} : { apiKey: literal },
|
|
135
|
+
resolveApiKey: async () => {
|
|
136
|
+
const credentials = ctx.get("credentials");
|
|
137
|
+
if (credentials !== void 0) return (await credentials.resolve(apiKeyEnv))?.value;
|
|
138
|
+
const ambient = launchEnvironmentOf(ctx).get(apiKeyEnv);
|
|
139
|
+
return ambient !== void 0 && ambient.value.length > 0 ? ambient.value : void 0;
|
|
140
|
+
},
|
|
141
|
+
apiKeyEnv,
|
|
142
|
+
baseURL: config.baseURL ?? "https://ollama.com",
|
|
143
|
+
...config.maxResults === void 0 ? {} : { maxResults: config.maxResults }
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
//#endregion
|
|
147
|
+
export { Config, OLLAMA_DEFAULT_BASE_URL, OLLAMA_DEFAULT_MAX_RESULTS, OLLAMA_MAX_RESULTS_LIMIT, OLLAMA_PROVIDER_ID, OllamaSearchProvider, apply, inject, mapOllamaResponse, mapOllamaResult, name };
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@morlay/dsh-web-search-ollama",
|
|
3
|
+
"version": "0.0.1-alpha.2",
|
|
4
|
+
"description": "Ollama-backed search provider for the DeepSeek Harness web seam (ctx.web): one web_search backend calling POST https://ollama.com/api/web_search.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh",
|
|
7
|
+
"dsh-plugin",
|
|
8
|
+
"ollama",
|
|
9
|
+
"web-search"
|
|
10
|
+
],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "https://github.com/morlay/dsh-plugin.git"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"src",
|
|
19
|
+
"!**/__tests__"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": "./dist/index.mjs",
|
|
24
|
+
"./package.json": "./package.json"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@deepseek-ai/schemastery": "3.18.3"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@deepseek-ai/cordis": "4.0.3",
|
|
31
|
+
"@deepseek-ai/dsh-credentials": "0.1.7-alpha.1",
|
|
32
|
+
"@deepseek-ai/dsh-launch-environment": "0.1.7-alpha.1",
|
|
33
|
+
"@deepseek-ai/dsh-web": "0.1.7-alpha.1"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "pnpm exec tsdown",
|
|
37
|
+
"test:e2e": "pnpm exec vitest run --config vitest.e2e.config.ts"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Context } from "@deepseek-ai/cordis";
|
|
2
|
+
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
3
|
+
import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
|
|
4
|
+
import type {} from "@deepseek-ai/dsh-web";
|
|
5
|
+
import z from "@deepseek-ai/schemastery";
|
|
6
|
+
import { OLLAMA_DEFAULT_BASE_URL, OllamaSearchProvider } from "./provider.ts";
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
OLLAMA_DEFAULT_BASE_URL,
|
|
10
|
+
OLLAMA_DEFAULT_MAX_RESULTS,
|
|
11
|
+
OLLAMA_MAX_RESULTS_LIMIT,
|
|
12
|
+
OLLAMA_PROVIDER_ID,
|
|
13
|
+
OllamaSearchProvider,
|
|
14
|
+
mapOllamaResponse,
|
|
15
|
+
mapOllamaResult,
|
|
16
|
+
} from "./provider.ts";
|
|
17
|
+
export type { OllamaSearchProviderOptions } from "./provider.ts";
|
|
18
|
+
|
|
19
|
+
export const name = "web-search-ollama";
|
|
20
|
+
|
|
21
|
+
/** 只往 `ctx.web` 注册后端,不发布服务。 */
|
|
22
|
+
export const inject = ["web"];
|
|
23
|
+
|
|
24
|
+
/** 与 `llm-pi-ai` 的 ollama route 共用同一个 key 引用。 */
|
|
25
|
+
const DEFAULT_API_KEY_ENV = "OLLAMA_API_KEY";
|
|
26
|
+
|
|
27
|
+
export interface Config {
|
|
28
|
+
/** 字面 key;优先用 `apiKeyEnv` 走凭证服务,别把密钥写进配置文件。 */
|
|
29
|
+
apiKey?: string;
|
|
30
|
+
/** 每次搜索解析一次的凭证引用;缺省 `OLLAMA_API_KEY`。 */
|
|
31
|
+
apiKeyEnv?: string;
|
|
32
|
+
/** 端点根;`/api/web_search` 由 provider 拼。缺省 ollama.com。 */
|
|
33
|
+
baseURL?: string;
|
|
34
|
+
/** 请求没带 `maxResults` 时的默认结果数;省略表示让 Ollama 用自己的默认。 */
|
|
35
|
+
maxResults?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const Config: z<Config> = z.object({
|
|
39
|
+
apiKey: z.string().role("secret"),
|
|
40
|
+
apiKeyEnv: z.string().role("credential-ref"),
|
|
41
|
+
baseURL: z.string(),
|
|
42
|
+
maxResults: z.number().step(1).min(1),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export function apply(ctx: Context, config: Config): void {
|
|
46
|
+
const apiKeyEnv = credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV);
|
|
47
|
+
const literal =
|
|
48
|
+
config.apiKey !== undefined && config.apiKey.length > 0 ? config.apiKey : undefined;
|
|
49
|
+
ctx.web.registerSearchProvider(
|
|
50
|
+
new OllamaSearchProvider({
|
|
51
|
+
...(literal === undefined ? {} : { apiKey: literal }),
|
|
52
|
+
resolveApiKey: async () => {
|
|
53
|
+
const credentials = ctx.get("credentials");
|
|
54
|
+
if (credentials !== undefined) return (await credentials.resolve(apiKeyEnv))?.value;
|
|
55
|
+
// 没有凭证服务时环境就是整个凭证面。
|
|
56
|
+
const ambient = launchEnvironmentOf(ctx).get(apiKeyEnv);
|
|
57
|
+
return ambient !== undefined && ambient.value.length > 0 ? ambient.value : undefined;
|
|
58
|
+
},
|
|
59
|
+
apiKeyEnv,
|
|
60
|
+
baseURL: config.baseURL ?? OLLAMA_DEFAULT_BASE_URL,
|
|
61
|
+
...(config.maxResults === undefined ? {} : { maxResults: config.maxResults }),
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
}
|
package/src/provider.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { WebError } from "@deepseek-ai/dsh-web";
|
|
2
|
+
import type {
|
|
3
|
+
WebSearchProvider,
|
|
4
|
+
WebSearchRequest,
|
|
5
|
+
WebSearchResult,
|
|
6
|
+
WebSearchSource,
|
|
7
|
+
} from "@deepseek-ai/dsh-web";
|
|
8
|
+
import type { OllamaError, OllamaSearchResponse, OllamaSearchResult } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
/** 注册进 `ctx.web` 的 id;与 llm route 同名,配置里看到的就是同一个"ollama"。 */
|
|
11
|
+
export const OLLAMA_PROVIDER_ID = "ollama";
|
|
12
|
+
|
|
13
|
+
/** 默认端点;`/api/web_search` 是操作。 */
|
|
14
|
+
export const OLLAMA_DEFAULT_BASE_URL = "https://ollama.com";
|
|
15
|
+
|
|
16
|
+
/** Ollama 服务端自己的默认结果数。 */
|
|
17
|
+
export const OLLAMA_DEFAULT_MAX_RESULTS = 5;
|
|
18
|
+
|
|
19
|
+
/** Ollama `max_results` 的硬上限;超过它的值不发给服务端。 */
|
|
20
|
+
export const OLLAMA_MAX_RESULTS_LIMIT = 10;
|
|
21
|
+
|
|
22
|
+
/** 每次请求带的归属头。 */
|
|
23
|
+
const USER_AGENT = "morlay-dsh-plugin/0.0.1";
|
|
24
|
+
|
|
25
|
+
export interface OllamaSearchProviderOptions {
|
|
26
|
+
/** 字面 key;给了就压过 {@link resolveApiKey}。 */
|
|
27
|
+
apiKey?: string;
|
|
28
|
+
/** 运行期取 key 的入口(凭证服务 / 环境);返回空表示取不到。 */
|
|
29
|
+
resolveApiKey?: () => Promise<string | undefined>;
|
|
30
|
+
/** 取不到 key 时报出来的引用名,便于用户知道去哪配。 */
|
|
31
|
+
apiKeyEnv: string;
|
|
32
|
+
baseURL: string;
|
|
33
|
+
/** 请求没给 `maxResults` 时用的默认值;省略表示让 Ollama 用它自己的默认。 */
|
|
34
|
+
maxResults?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 一条 Ollama 结果 → 一个 source;没有 url 就丢掉(seam 的 source 必须有 url)。 */
|
|
38
|
+
export function mapOllamaResult(result: OllamaSearchResult): WebSearchSource | undefined {
|
|
39
|
+
const url = result.url;
|
|
40
|
+
if (typeof url !== "string" || url.length === 0) return undefined;
|
|
41
|
+
const title =
|
|
42
|
+
typeof result.title === "string" && result.title.length > 0 ? result.title : undefined;
|
|
43
|
+
const snippet =
|
|
44
|
+
typeof result.content === "string" && result.content.length > 0 ? result.content : undefined;
|
|
45
|
+
return {
|
|
46
|
+
url,
|
|
47
|
+
...(title === undefined ? {} : { title }),
|
|
48
|
+
...(snippet === undefined ? {} : { snippet }),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 响应信封 → 归一化结果;形状不对就抛(由调用方包成 `WEB_PROVIDER_ERROR`)。 */
|
|
53
|
+
export function mapOllamaResponse(response: OllamaSearchResponse): WebSearchResult {
|
|
54
|
+
const results = response.results;
|
|
55
|
+
if (results !== undefined && !Array.isArray(results)) {
|
|
56
|
+
throw new TypeError("Ollama returned a non-array `results` field");
|
|
57
|
+
}
|
|
58
|
+
const sources = (results ?? [])
|
|
59
|
+
.map((result) => mapOllamaResult(result))
|
|
60
|
+
.filter((source): source is WebSearchSource => source !== undefined);
|
|
61
|
+
// Ollama 不返回生成式答案(content 是每条结果的片段),所以没有 result.content;
|
|
62
|
+
// 截断由 seam 负责,这里照实报 false。
|
|
63
|
+
return { sources, truncated: false };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Ollama 的搜索后端;HTTP 重定向按 `WEB_PROVIDER_ERROR` 失败。 */
|
|
67
|
+
export class OllamaSearchProvider implements WebSearchProvider {
|
|
68
|
+
readonly id = OLLAMA_PROVIDER_ID;
|
|
69
|
+
|
|
70
|
+
constructor(private readonly options: OllamaSearchProviderOptions) {}
|
|
71
|
+
|
|
72
|
+
available(): boolean {
|
|
73
|
+
const { apiKey, resolveApiKey, baseURL, maxResults } = this.options;
|
|
74
|
+
const hasKey = (apiKey !== undefined && apiKey.length > 0) || resolveApiKey !== undefined;
|
|
75
|
+
return (
|
|
76
|
+
hasKey && URL.canParse(baseURL) && (maxResults === undefined || isPositiveInteger(maxResults))
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
|
|
81
|
+
const apiKey = await this.apiKey();
|
|
82
|
+
const maxResults = clampMaxResults(request.maxResults ?? this.options.maxResults);
|
|
83
|
+
let response: Response;
|
|
84
|
+
try {
|
|
85
|
+
response = await fetch(`${this.options.baseURL}/api/web_search`, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
redirect: "error",
|
|
88
|
+
headers: {
|
|
89
|
+
authorization: `Bearer ${apiKey}`,
|
|
90
|
+
"content-type": "application/json",
|
|
91
|
+
accept: "application/json",
|
|
92
|
+
"user-agent": USER_AGENT,
|
|
93
|
+
},
|
|
94
|
+
body: JSON.stringify({
|
|
95
|
+
query: request.query,
|
|
96
|
+
...(maxResults === undefined ? {} : { max_results: maxResults }),
|
|
97
|
+
}),
|
|
98
|
+
...(signal === undefined ? {} : { signal }),
|
|
99
|
+
});
|
|
100
|
+
} catch (error: unknown) {
|
|
101
|
+
if (isAbortError(error)) throw searchAborted(error);
|
|
102
|
+
throw new WebError(`Ollama search request failed: ${String(error)}`, "WEB_PROVIDER_ERROR", {
|
|
103
|
+
cause: error,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!response.ok) {
|
|
108
|
+
const status = response.status;
|
|
109
|
+
let message = `Ollama API error (HTTP ${status})`;
|
|
110
|
+
try {
|
|
111
|
+
const parsed = (await response.json()) as OllamaError;
|
|
112
|
+
const detail = parsed.error;
|
|
113
|
+
if (typeof detail === "string" && detail.length > 0) message = detail;
|
|
114
|
+
} catch (error: unknown) {
|
|
115
|
+
// 取消不能吞成"HTTP 错误":取消是 seam 的取消契约,不是 provider 故障。
|
|
116
|
+
if (isAbortError(error)) throw searchAborted(error);
|
|
117
|
+
// 其余情况状态行已经在 message 里,错误体不是 JSON 只损失一句更细的说明。
|
|
118
|
+
}
|
|
119
|
+
throw new WebError(message, "WEB_PROVIDER_ERROR");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
return mapOllamaResponse((await response.json()) as OllamaSearchResponse);
|
|
124
|
+
} catch (error: unknown) {
|
|
125
|
+
if (isAbortError(error)) throw searchAborted(error);
|
|
126
|
+
throw new WebError(
|
|
127
|
+
`Ollama returned an unprocessable response body: ${String(error)}`,
|
|
128
|
+
"WEB_PROVIDER_ERROR",
|
|
129
|
+
{
|
|
130
|
+
cause: error,
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 一次操作一份 key,不留在 provider 上;没有可用的 key 就点名引用,而不是发无授权请求。 */
|
|
137
|
+
private async apiKey(): Promise<string> {
|
|
138
|
+
const literal = this.options.apiKey;
|
|
139
|
+
if (literal !== undefined && literal.length > 0) return literal;
|
|
140
|
+
let resolved: string | undefined;
|
|
141
|
+
try {
|
|
142
|
+
resolved = await this.options.resolveApiKey?.();
|
|
143
|
+
} catch (error: unknown) {
|
|
144
|
+
if (isAbortError(error)) throw searchAborted(error);
|
|
145
|
+
throw new WebError(
|
|
146
|
+
`Ollama search credential resolution failed: ${String(error)}`,
|
|
147
|
+
"WEB_PROVIDER_ERROR",
|
|
148
|
+
{
|
|
149
|
+
cause: error,
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
if (resolved !== undefined && resolved.length > 0) return resolved;
|
|
154
|
+
throw new WebError(
|
|
155
|
+
`Ollama search has no API key for "${this.options.apiKeyEnv}"; store it through the credentials service` +
|
|
156
|
+
' (the web Models page writes it), export it in the launching environment, or set a literal "apiKey"' +
|
|
157
|
+
" in the web-search-ollama config",
|
|
158
|
+
"WEB_PROVIDER_CREDENTIAL_MISSING",
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function clampMaxResults(maxResults: number | undefined): number | undefined {
|
|
164
|
+
if (maxResults === undefined) return undefined;
|
|
165
|
+
return Math.min(maxResults, OLLAMA_MAX_RESULTS_LIMIT);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function isPositiveInteger(value: number): boolean {
|
|
169
|
+
return Number.isInteger(value) && value > 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function isAbortError(error: unknown): boolean {
|
|
173
|
+
return error instanceof DOMException && error.name === "AbortError";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function searchAborted(cause: unknown): WebError {
|
|
177
|
+
return new WebError("Ollama search aborted", "WEB_ABORTED", { cause });
|
|
178
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST https://ollama.com/api/web_search 的 wire 类型。只有类型,没有运行期代码。
|
|
3
|
+
*
|
|
4
|
+
* 响应是扁平 `results[]`,每条带 title / url / content;`content` 是 Ollama 自己截出来的
|
|
5
|
+
* 网页片段(不是生成式答案),所以它只映射到 `WebSearchSource.snippet`。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** 请求体。`max_results` 缺省时由服务端按自己的默认值(5)处理。 */
|
|
9
|
+
export interface OllamaSearchRequest {
|
|
10
|
+
query: string;
|
|
11
|
+
max_results?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** `results[]` 的一条;字段都可能缺失或为空串。 */
|
|
15
|
+
export interface OllamaSearchResult {
|
|
16
|
+
title?: string | null;
|
|
17
|
+
url?: string | null;
|
|
18
|
+
content?: string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 成功响应信封。 */
|
|
22
|
+
export interface OllamaSearchResponse {
|
|
23
|
+
results?: OllamaSearchResult[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 错误响应信封;Ollama 失败时给 `{"error": "..."}`。 */
|
|
27
|
+
export interface OllamaError {
|
|
28
|
+
error?: string;
|
|
29
|
+
}
|