返回 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 approveWorkspace(t, dir)
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