1package sdk
2
3import (
4 "context"
5 "crypto/sha256"
6 "encoding/json"
7 "fmt"
8 "io"
9 "net/http"
10 "net/http/httptest"
11 "path/filepath"
12 "strings"
13 "testing"
14 "time"
15
16 "github.com/bnema/zut/packages/provider"
17)
18
19func testCredentialEndpointScope(credential, baseURL string) string {
20 endpoint := strings.TrimRight(strings.TrimSpace(baseURL), "/")
21 if endpoint == "" {
22 endpoint = provider.OpenCodeGoDefaultBaseURL
23 }
24 digest := sha256.Sum256([]byte(credential + "\x00" + endpoint))
25 return fmt.Sprintf("sha256:%x", digest)
26}
27
28func TestNewLoadsCachedOpenCodeGoMetadataBeforeResolving(t *testing.T) {
29 const (
30 apiKey = "synthetic-opencode-key"
31 modelID = "cached-opencode-model"
32 )
33 home := t.TempDir()
34 t.Setenv("ZUT_HOME", home)
35 provider.SetLiveModels(nil)
36 provider.SetUserModels(nil)
37 t.Cleanup(func() {
38 provider.SetLiveModels(nil)
39 provider.SetUserModels(nil)
40 })
41
42 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
43 if r.URL.Path != "/v1/chat/completions" {
44 http.NotFound(w, r)
45 return
46 }
47 if got := r.Header.Get("authorization"); got != "Bearer "+apiKey {
48 t.Errorf("authorization = %q, want bearer API key", got)
49 }
50 body, err := io.ReadAll(r.Body)
51 if err != nil {
52 t.Errorf("read request: %v", err)
53 }
54 var request struct {
55 Model string `json:"model"`
56 ReasoningEffort string `json:"reasoning_effort"`
57 }
58 if err := json.Unmarshal(body, &request); err != nil {
59 t.Errorf("decode request: %v", err)
60 }
61 if request.Model != modelID {
62 t.Errorf("request model = %q, want %q", request.Model, modelID)
63 }
64 if request.ReasoningEffort != "minimal" {
65 t.Errorf("request reasoning_effort = %q, want minimal", request.ReasoningEffort)
66 }
67 w.Header().Set("Content-Type", "text/event-stream")
68 _, _ = fmt.Fprintln(w, `data: {"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}]}`)
69 _, _ = fmt.Fprintln(w)
70 _, _ = fmt.Fprintln(w, "data: [DONE]")
71 }))
72 defer server.Close()
73
74 cached := provider.Model{
75 Provider: provider.ProviderOpenCodeGo,
76 ID: modelID,
77 DisplayName: "Cached OpenCode model",
78 Reasoning: true,
79 ReasoningLevelMap: map[string]string{"minimum": "minimum", "low": "", "medium": "", "high": "", "xhigh": "", "max": ""},
80 ReasoningEffortMap: map[string]string{"minimum": "minimal"},
81 ContextWindow: 128000,
82 MaxOutput: 8192,
83 BaseURL: server.URL + "/v1",
84 Source: "live",
85 }
86 if err := provider.SaveCache(filepath.Join(home, "models-cache.json"), provider.ModelCache{
87 Version: provider.ModelCacheVersion,
88 FetchedAt: time.Now().UTC(),
89 Models: []provider.Model{cached},
90 AuthoritativeProviders: []string{provider.ProviderOpenCodeGo},
91 ProviderScopes: map[string]string{provider.ProviderOpenCodeGo: testCredentialEndpointScope(apiKey, server.URL+"/v1")},
92 }); err != nil {
93 t.Fatal(err)
94 }
95
96 runtime, err := New(Config{
97 Provider: provider.ProviderOpenCodeGo,
98 Model: " " + modelID + " ",
99 APIKey: apiKey,
100 BaseURL: server.URL + "/v1",
101 Reasoning: "minimum",
102 CWD: t.TempDir(),
103 NoTools: true,
104 })
105 if err != nil {
106 t.Fatal(err)
107 }
108 defer runtime.Close()
109
110 models := runtime.ListModels()
111 var found bool
112 for _, model := range models {
113 if model.ID == modelID {
114 found = model.ContextWindow == 128000 && model.MaxOutput == 8192 && model.Reasoning
115 }
116 }
117 if !found {
118 t.Fatalf("SDK did not load cached OpenCode Go metadata: %+v", models)
119 }
120 if err := runtime.SetModel(" " + modelID + " "); err != nil {
121 t.Fatal(err)
122 }
123 if runtime.Model() != modelID {
124 t.Fatalf("SDK model = %q, want trimmed id", runtime.Model())
125 }
126
127 events, err := runtime.Prompt(context.Background(), "hello", nil)
128 if err != nil {
129 t.Fatal(err)
130 }
131 for event := range events {
132 if event.Type == "error" {
133 t.Fatalf("prompt error: %s", event.Error)
134 }
135 }
136}