返回 DeepSeek-Reasonix
extension_provider_test.go
根目录 / internal / boot / extension_provider_test.go
1 package boot
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "maps"
8 "os"
9 "strings"
10 "testing"
11 "time"
12
13 "reasonix/internal/config"
14 "reasonix/internal/extension/providerext"
15 "reasonix/internal/provider"
16 )
17
18 // Stage 7 end-to-end coverage: a fake sidecar declares and streams an
19 // extension-hosted provider through the merged resolver BuildRuntime exposes.
20
21 // bootWithProviderPlugin installs the fake sidecar in provider mode and
22 // returns the build result.
23 func bootWithProviderPlugin(t *testing.T, name string, runtime map[string]any) *BuildResult {
24 t.Helper()
25 if runtime == nil {
26 runtime = map[string]any{}
27 }
28 if _, ok := runtime["capabilities"]; !ok {
29 runtime["capabilities"] = []string{"providers"}
30 }
31 env := map[string]string{
32 bootFakeEnvPluginName: name,
33 bootFakeEnvProvider: "1",
34 }
35 if extra, ok := runtime["env"].(map[string]string); ok {
36 maps.Copy(env, extra)
37 }
38 runtime["env"] = env
39 return bootWithFakePlugin(t, name, runtime)
40 }
41
42 func collectProviderChunks(t *testing.T, out <-chan provider.Chunk) []provider.Chunk {
43 t.Helper()
44 var chunks []provider.Chunk
45 for {
46 select {
47 case chunk, ok := <-out:
48 if !ok {
49 return chunks
50 }
51 chunks = append(chunks, chunk)
52 case <-time.After(10 * time.Second):
53 t.Fatal("provider stream did not close")
54 }
55 }
56 }
57
58 func TestBootExtensionProviderStreamsEndToEnd(t *testing.T) {
59 res := bootWithProviderPlugin(t, "providerdemo", nil)
60 if res.ProviderResolver == nil {
61 t.Fatal("BuildRuntime returned no ProviderResolver")
62 }
63
64 // The merged catalog carries the sidecar's provider next to the config's.
65 var found *provider.Descriptor
66 for _, d := range res.ProviderResolver.Catalog() {
67 if d.Ref == "plugin/providerdemo/fake/x" {
68 copy := d
69 found = &copy
70 }
71 }
72 if found == nil {
73 t.Fatalf("merged catalog = %v, want plugin/providerdemo/fake/x", res.ProviderResolver.Catalog())
74 }
75 if found.DisplayName != "Boot Fake" || found.Model != "x" || !found.Tools || !found.Reasoning {
76 t.Fatalf("sidecar descriptor = %+v", found)
77 }
78
79 p, err := res.ProviderResolver.Resolve(provider.Selection{Ref: "plugin/providerdemo/fake/x"})
80 if err != nil {
81 t.Fatalf("Resolve: %v", err)
82 }
83 if p.Name() != "plugin" {
84 t.Fatalf("Name() = %q", p.Name())
85 }
86 out, err := p.Stream(context.Background(), provider.Request{
87 Messages: []provider.Message{{Role: provider.RoleUser, Content: "say hi"}},
88 MaxTokens: 32,
89 })
90 if err != nil {
91 t.Fatalf("Stream: %v", err)
92 }
93 chunks := collectProviderChunks(t, out)
94 if len(chunks) != 3 {
95 t.Fatalf("chunks = %+v, want text, text, usage", chunks)
96 }
97 if chunks[0].Type != provider.ChunkText || chunks[0].Text != "fake-hello " ||
98 chunks[1].Type != provider.ChunkText || chunks[1].Text != "fake-world" {
99 t.Fatalf("text chunks = %+v", chunks[:2])
100 }
101 if chunks[2].Type != provider.ChunkUsage || chunks[2].Usage == nil ||
102 chunks[2].Usage.TotalTokens != 12 || chunks[2].Usage.CacheHitTokens != 2 ||
103 chunks[2].Usage.ReasoningTokens != 4 || chunks[2].Usage.FinishReason != "stop" {
104 t.Fatalf("usage chunk = %+v", chunks[2])
105 }
106
107 // The base resolver still serves the config's own model.
108 base, err := res.ProviderResolver.Resolve(provider.Selection{Ref: "test-model/x"})
109 if err != nil {
110 t.Fatalf("Resolve base: %v", err)
111 }
112 if base.Name() != "test-model" {
113 t.Fatalf("base provider name = %q", base.Name())
114 }
115 }
116
117 // writeRuntimeFixtureWithConflictingProvider writes the shared fixture plus a
118 // config provider whose synthesized ref matches the fake sidecar's ref.
119 func writeRuntimeFixtureWithConflictingProvider(t *testing.T, dir, name string) {
120 t.Helper()
121 writeRuntimeFixture(t, dir)
122 appendRuntimeFixture(t, dir, fmt.Sprintf(`
123 [[providers]]
124 name = "plugin"
125 kind = "openai"
126 base_url = "https://example.invalid"
127 model = "%s/fake/x"
128 api_key_env = "REASONIX_TEST_KEY_UNSET"
129 `, name))
130 }
131
132 func appendRuntimeFixture(t *testing.T, dir, extra string) {
133 t.Helper()
134 path := dir + "/reasonix.toml"
135 existing, err := os.ReadFile(path)
136 if err != nil {
137 t.Fatalf("ReadFile: %v", err)
138 }
139 if err := os.WriteFile(path, append(existing, []byte(extra)...), 0o644); err != nil {
140 t.Fatalf("WriteFile: %v", err)
141 }
142 }
143
144 func TestBootFailsOnUnclaimedExtensionProviderConflict(t *testing.T) {
145 isolateConfigHome(t)
146 dir := robustTempDir(t)
147 t.Chdir(dir)
148 name := "conflicter"
149 writeRuntimeFixtureWithConflictingProvider(t, dir, name)
150 installBootFakePlugin(t, config.ReasonixHomeDir(), name, map[string]any{
151 "capabilities": []string{"providers"},
152 "env": map[string]string{
153 bootFakeEnvPluginName: name,
154 bootFakeEnvProvider: "1",
155 },
156 })
157
158 _, err := BuildRuntime(context.Background(), Options{})
159 if err == nil {
160 t.Fatal("BuildRuntime succeeded with an unclaimed provider conflict")
161 }
162 var conflictErr *providerext.ConflictError
163 if !errors.As(err, &conflictErr) {
164 t.Fatalf("error %v is not a providerext.ConflictError", err)
165 }
166 ref := "plugin/" + name + "/fake/x"
167 if !strings.Contains(err.Error(), ref) || !strings.Contains(err.Error(), `"`+name+`"`) ||
168 !strings.Contains(err.Error(), "provider:"+ref) {
169 t.Fatalf("conflict error = %q, want ref, plugin, and slot named", err)
170 }
171 }
172
173 func TestBootExtensionProviderConflictWithClaimSidecarWins(t *testing.T) {
174 isolateConfigHome(t)
175 dir := robustTempDir(t)
176 t.Chdir(dir)
177 name := "claimerdemo"
178 writeRuntimeFixtureWithConflictingProvider(t, dir, name)
179 ref := "plugin/" + name + "/fake/x"
180 res := bootWithProviderPlugin(t, name, map[string]any{
181 "replaces": []string{"provider:" + ref},
182 })
183
184 var found *provider.Descriptor
185 for _, d := range res.ProviderResolver.Catalog() {
186 if d.Ref == ref {
187 copy := d
188 found = &copy
189 }
190 }
191 if found == nil {
192 t.Fatalf("merged catalog = %v, want %s", res.ProviderResolver.Catalog(), ref)
193 }
194 if found.DisplayName != "Boot Fake" {
195 t.Fatalf("contested descriptor = %+v, want the claiming sidecar's entry", found)
196 }
197
198 p, err := res.ProviderResolver.Resolve(provider.Selection{Ref: ref})
199 if err != nil {
200 t.Fatalf("Resolve: %v", err)
201 }
202 out, err := p.Stream(context.Background(), provider.Request{
203 Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
204 })
205 if err != nil {
206 t.Fatalf("Stream: %v", err)
207 }
208 chunks := collectProviderChunks(t, out)
209 if len(chunks) != 3 || chunks[0].Text != "fake-hello " {
210 t.Fatalf("chunks = %+v, want the sidecar's stream", chunks)
211 }
212 }
213
213 lines GO