Files
HomeAgent/providers/ollama/generator_test.go
JianFeeeee 6d38a164eb chore(module): Go module 路径随托管地迁移 gitcode → github
## 背景

仓库早已迁移到 GitHub(`github.com/JianFeeeee/HomeAgent`,CI/CD 都在那边跑),
gitcode 保留为**国内只读镜像**(`ci.yml` 的 sync-gitcode-main 快进推送)。
但 go.mod 的 module 路径一直是 `gitcode.com/JianFeeeee/...` —— 既与权威源
不符,`meta.SourceURL`(AGPL §13 的源码提供入口)也指向镜像而非实际运行的代码。

## 关键区分(这是本次改动安全的前提)

两个 module 是**不同**的,影响面差一个数量级:

| module | 谁依赖 | 改它的后果 |
|---|---|---|
| `.../HomeAgent`(主仓) | **只有主仓自己** | 内部重构,零外部影响 |
| `.../homeagent-sdk`(SDK 仓) | 主仓 49 文件 + 所有外部插件 | 需重编插件 |

★ 实测确认:**插件不 import 主仓 module**(`strings qq/plugin.bin | grep HomeAgent` = 0),
只依赖 SDK 仓。而插件与内核的边界是 **RPC 方法名字符串**
(`plugin.init` / `tool.invoke`…,见 proc/protocol.go),**与 Go module 名无关**。
⇒ module 改名**不改变任何 wire 协议**,现网已装的插件照常运行。

SDK 的 GitHub 仓名是 `homeagentsdk`(无连字符),module 路径必须与之精确匹配,
否则 `go get` 会因路径不符失败,故取 `github.com/JianFeeeee/homeagentsdk`。

## 改动

- `go.mod` / `go.sum`:两个 module 路径 + require/replace
- 277 个 Go 文件 + Makefile + 打包脚本的 import 与 ldflags 路径
- SDK 仓 `meta.CoreModule`、`tools/hmapdev` 的 module 声明与工程模板
- 对外文档/链接的仓库地址(README、PLUGIN_DEV、OVERVIEW 等)

## 明确**保留** gitcode 的地方(镜像机制,不能动)

- `GITCODE_TOKEN`、`GC_URL="…@gitcode.com"`(CI 同步 job)
- `gitcode.com/api/v5/...`(release 附件上传)
- git remote `gitcode`(镜像拉取/推送)
- `docs/zh/ci-cd-runbook.md`(整篇讲镜像运维)

## 验证

- `go build ./...` / `go vet ./...` 绿
- `go test ./... -count=1` → 48 包 ok / 0 FAIL
- hmapdev 工具链编译通过(`third_party/homeagent-sdk/tools/hmapdev`)
- 全仓残留检查:module 形态的 `gitcode.com/JianFeeeee` = 0
- 镜像机制抽验:GC_URL / @gitcode / api/v5 / remote 均完好

## 顺带修正

`hmapdev/templates.go` 的注释此前写「gitcode 的模块不在 proxy.golang.org 上」——
module 已迁 GitHub,该论断不再成立。改为如实说明**为何仍默认 replace 到本机 SDK**:
离线可构建、避免版本漂移、内网可用。理由与托管地无关,故 replace 保留。
2026-10-08 10:40:17 +08:00

166 lines
5.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package ollamagen
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/JianFeeeee/HomeAgent/pkg/generation"
)
// 测试不依赖真模型:起一个本地 httptest server 模拟 Ollama,
// 断言 Go 侧发出的请求形态(schema 透传、线程参数、think:false)与解析正确。
func newTestProvider(t *testing.T, handler http.HandlerFunc) (*Provider, *httptest.Server) {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
p, err := New(generation.Config{Options: map[string]string{
"endpoint": srv.URL + "/api/generate",
}})
if err != nil {
t.Fatalf("New: %v", err)
}
t.Cleanup(p.Close)
return p, srv
}
// ★ 关键契约:JSONSchema 必须原样透传成 Ollama 的 format 字段。
// 实测证明这是 1.7b 零幻觉的唯一机制——丢了它模型就输出聊天腔。
func TestGenerate_透传Schema到Format(t *testing.T) {
schema := `{"type":"object","properties":{"fields":{"type":"array"}}}`
var gotFormat map[string]any
var gotThink bool
var gotThread int = -1
p, _ := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Errorf("decode: %v", err)
}
if f, ok := req["format"].(map[string]any); ok {
gotFormat = f
}
gotThink, _ = req["think"].(bool)
if opts, ok := req["options"].(map[string]any); ok {
if v, ok := opts["num_thread"].(float64); ok {
gotThread = int(v)
}
}
_ = json.NewEncoder(w).Encode(map[string]any{
"response": `{"fields":[]}`, "done": true, "done_reason": "stop",
})
})
resp, err := p.Generate(context.Background(), generation.Request{
Prompt: "测试", MaxTokens: 64, JSONSchema: schema,
})
if err != nil {
t.Fatalf("Generate: %v", err)
}
// schema 作为对象透传(json.RawMessage),不是字符串包装
if gotFormat == nil {
t.Error("format 未透传:服务端未收到 format 字段")
} else if _, ok := gotFormat["properties"]; !ok {
t.Errorf("format 应保留 schema 的 properties 键,实际 %v", gotFormat)
}
if gotThink {
t.Errorf("think 应为 false(实测不关思考 1.7b 会先输出大段思考再答案)")
}
if gotThread != defaultThreads {
t.Errorf("num_thread 应默认 %d,实际 %v", defaultThreads, gotThread)
}
if resp.Text != `{"fields":[]}` {
t.Errorf("Text=%q", resp.Text)
}
if resp.Truncated {
t.Errorf("stop 不应报 Truncated")
}
}
// 非法 schema 必须在本地拒绝,而不是发出去让服务端报含糊的错。
func TestGenerate_非法Schema本地拒绝(t *testing.T) {
p, _ := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("不应发出请求")
})
_, err := p.Generate(context.Background(), generation.Request{
Prompt: "x", JSONSchema: "{not json",
})
if err == nil || !strings.Contains(err.Error(), "not valid JSON") {
t.Fatalf("应报 schema 非法,实际: %v", err)
}
}
// HTTP 错误要带状态码和响应片段,不能只给 "request failed"。
func TestGenerate_HTTP错误带响应体(t *testing.T) {
p, _ := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`{"error":"model busy"}`))
})
_, err := p.Generate(context.Background(), generation.Request{Prompt: "x"})
if err == nil || !strings.Contains(err.Error(), "503") || !strings.Contains(err.Error(), "model busy") {
t.Fatalf("错误信息应含状态码与响应体,实际: %v", err)
}
}
// 服务端报 error 字段时必须转发给调用方。
func TestGenerate_服务端Error字段(t *testing.T) {
p, _ := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"error": "model not found"})
})
_, err := p.Generate(context.Background(), generation.Request{Prompt: "x"})
if err == nil || !strings.Contains(err.Error(), "model not found") {
t.Fatalf("应转发服务端 error,实际: %v", err)
}
}
// done_reason=length 必须映射成 Truncated,让调用方对截断的结构化输出起疑。
func TestGenerate_截断标记(t *testing.T) {
p, _ := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"response": `{"fields":[{"name":"a","value":"1"},{"name":"b","va`,
"done": true, "done_reason": "length",
})
})
resp, err := p.Generate(context.Background(), generation.Request{Prompt: "x"})
if err != nil {
t.Fatalf("Generate: %v", err)
}
if !resp.Truncated {
t.Fatal("done_reason=length 应标记 Truncated")
}
}
// SPI 注册表联通:名字必须能从注册表里 Open 出来(核心的引用方式)。
func TestProvider通过SPI注册表打开(t *testing.T) {
if _, err := generation.Open("__不存在__", generation.Config{}); err == nil {
t.Fatal("未知 provider 应报错")
}
// ollama 注册本身不建连接(New 不 ping),可以安全 Open
p, err := generation.Open("ollama", generation.Config{Options: map[string]string{
"model": "test-model",
}})
if err != nil {
t.Fatalf("Open(ollama): %v", err)
}
defer p.Close()
if info := p.Info(); info.Model != "test-model" || !info.SupportsJSONSchema {
t.Errorf("Info=%+v", info)
}
}
// 未设/空白 model 回退到默认模型,而不是构造出一个空模型名的 provider。
func TestNew_空模型名回退默认(t *testing.T) {
p, err := New(generation.Config{Options: map[string]string{"model": " "}})
if err != nil {
t.Fatalf("空白 model 应回退默认而非失败: %v", err)
}
defer p.Close()
if p.Info().Model != defaultModel {
t.Errorf("应回退默认 %q,实际 %q", defaultModel, p.Info().Model)
}
}