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