1package sdk
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io"
8 "math"
9 "net/http"
10 "net/http/httptest"
11 "path/filepath"
12 "testing"
13 "time"
14
15 "github.com/bnema/zut/packages/provider"
16)
17
18func TestSDKRuntimeKeepsOpenCodeGoMetadataPerEndpoint(t *testing.T) {
19 const modelID = "scoped-opencode-model"
20 home := t.TempDir()
21 t.Setenv("ZUT_HOME", home)
22 provider.SetLiveModels(nil)
23 provider.SetUserModels(nil)
24 t.Cleanup(func() {
25 provider.SetLiveModels(nil)
26 provider.SetUserModels(nil)
27 })
28
29 newServer := func(apiKey, wantEffort string) *httptest.Server {
30 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
31 if r.URL.Path != "/v1/chat/completions" {
32 http.NotFound(w, r)
33 return
34 }
35 if got := r.Header.Get("authorization"); got != "Bearer "+apiKey {
36 t.Errorf("authorization = %q, want bearer key %q", got, apiKey)
37 }
38 body, err := io.ReadAll(r.Body)
39 if err != nil {
40 t.Errorf("read request: %v", err)
41 }
42 var request struct {
43 Model string `json:"model"`
44 ReasoningEffort string `json:"reasoning_effort"`
45 }
46 if err := json.Unmarshal(body, &request); err != nil {
47 t.Errorf("decode request: %v", err)
48 }
49 if request.Model != modelID || request.ReasoningEffort != wantEffort {
50 t.Errorf("request = model %q effort %q, want model %q effort %q", request.Model, request.ReasoningEffort, modelID, wantEffort)
51 }
52 w.Header().Set("Content-Type", "text/event-stream")
53 _, _ = fmt.Fprintln(w, `data: {"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`)
54 _, _ = fmt.Fprintln(w)
55 _, _ = fmt.Fprintln(w, "data: [DONE]")
56 }))
57 }
58 serverA := newServer("key-a", "minimal")
59 defer serverA.Close()
60 serverB := newServer("key-b", "")
61 defer serverB.Close()
62
63 saveCache := func(server *httptest.Server, apiKey string, reasoning bool, effort string) {
64 t.Helper()
65 model := provider.Model{
66 Provider: provider.ProviderOpenCodeGo,
67 ID: modelID,
68 Reasoning: reasoning,
69 ContextWindow: 128000,
70 MaxOutput: 8192,
71 PriceInput: 100,
72 PriceOutput: 200,
73 BaseURL: server.URL + "/v1",
74 }
75 if reasoning {
76 model.PriceInput = 10
77 model.PriceOutput = 20
78 }
79 if reasoning {
80 model.ReasoningLevelMap = map[string]string{"minimum": "minimum", "low": "", "medium": "", "high": "", "xhigh": "", "max": ""}
81 model.ReasoningEffortMap = map[string]string{"minimum": effort}
82 }
83 if err := provider.SaveCache(filepath.Join(home, "models-cache.json"), provider.ModelCache{
84 Version: provider.ModelCacheVersion,
85 FetchedAt: time.Now().UTC(),
86 Models: []provider.Model{model},
87 AuthoritativeProviders: []string{provider.ProviderOpenCodeGo},
88 ProviderScopes: map[string]string{provider.ProviderOpenCodeGo: testCredentialEndpointScope(apiKey, server.URL+"/v1")},
89 }); err != nil {
90 t.Fatal(err)
91 }
92 }
93
94 saveCache(serverA, "key-a", true, "minimal")
95 runtimeA, err := New(Config{
96 Provider: provider.ProviderOpenCodeGo,
97 Model: modelID,
98 APIKey: "key-a",
99 BaseURL: serverA.URL + "/v1",
100 Reasoning: "minimum",
101 CWD: t.TempDir(),
102 NoTools: true,
103 })
104 if err != nil {
105 t.Fatal(err)
106 }
107
108 // Replace the disk cache and process-global catalog with another scope.
109 // Runtime A must continue using its own endpoint metadata afterward.
110 saveCache(serverB, "key-b", false, "")
111 runtimeB, err := New(Config{
112 Provider: provider.ProviderOpenCodeGo,
113 Model: modelID,
114 APIKey: "key-b",
115 BaseURL: serverB.URL + "/v1",
116 Reasoning: "minimum",
117 CWD: t.TempDir(),
118 NoTools: true,
119 })
120 if err != nil {
121 runtimeA.Close()
122 t.Fatal(err)
123 }
124
125 prompt := func(runtime *Runtime) {
126 t.Helper()
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 }
137 prompt(runtimeB)
138 prompt(runtimeA)
139
140 if got, want := runtimeB.Cost().CostUSD, 0.0003; math.Abs(got-want) > 1e-12 {
141 t.Fatalf("runtime B cost = %v, want %v", got, want)
142 }
143 if got, want := runtimeA.Cost().CostUSD, 0.00003; math.Abs(got-want) > 1e-12 {
144 t.Fatalf("runtime A cost = %v, want %v", got, want)
145 }
146 if err := runtimeB.Close(); err != nil {
147 t.Fatal(err)
148 }
149 if err := runtimeA.Close(); err != nil {
150 t.Fatal(err)
151 }
152}