| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "net/http" |
| 7 | "net/http/httptest" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "testing" |
| 11 | |
| 12 | "reasonix/internal/config" |
| 13 | ) |
| 14 | |
| 15 | func TestProviderDraftProbesDoNotPersistCredentialsOrConfiguration(t *testing.T) { |
| 16 | isolateDesktopUserDirs(t) |
| 17 | const keyEnv = "REASONIX_DRAFT_PROBE_TEST_KEY" |
| 18 | if _, err := config.SetCredential(keyEnv, "saved-key"); err != nil { |
| 19 | t.Fatal(err) |
| 20 | } |
| 21 | t.Setenv(keyEnv, "process-key") |
| 22 | revision := config.CredentialStoreRevision() |
| 23 | cachePath := filepath.Join(config.CacheDir(), "model-capabilities-v2.json") |
| 24 | beforeCache, _ := os.ReadFile(cachePath) |
| 25 | var paths []string |
| 26 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 27 | paths = append(paths, r.URL.Path) |
| 28 | if r.Header.Get("Authorization") != "Bearer draft-key" { |
| 29 | http.Error(w, "wrong credential", http.StatusUnauthorized) |
| 30 | return |
| 31 | } |
| 32 | if r.URL.Path == "/v1/models" { |
| 33 | fmt.Fprint(w, `{"data":[{"id":"draft-model"}]}`) |
| 34 | return |
| 35 | } |
| 36 | if r.URL.Path != "/exact/chat" { |
| 37 | http.NotFound(w, r) |
| 38 | return |
| 39 | } |
| 40 | var body map[string]any |
| 41 | if err := json.NewDecoder(r.Body).Decode(&body); err != nil { |
| 42 | t.Error(err) |
| 43 | return |
| 44 | } |
| 45 | if body["model"] != "draft-model" || body["tools"] != nil { |
| 46 | t.Errorf("unexpected probe model/tools: %v", body) |
| 47 | } |
| 48 | messages, _ := body["messages"].([]any) |
| 49 | if len(messages) != 1 || messages[0].(map[string]any)["role"] != "user" { |
| 50 | t.Errorf("probe includes session/system context: %v", messages) |
| 51 | } |
| 52 | w.Header().Set("Content-Type", "text/event-stream") |
| 53 | fmt.Fprint(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"OK\"}}]}\n\ndata: [DONE]\n\n") |
| 54 | })) |
| 55 | defer srv.Close() |
| 56 | p := ProviderView{Name: "draft-only", Kind: "openai", BaseURL: srv.URL + "/v1", RequestURL: srv.URL + "/exact/chat", APIKeyEnv: keyEnv, Models: []string{"draft-model"}} |
| 57 | a := NewApp() |
| 58 | catalog, err := a.FetchProviderModelCatalogDraft(p, "draft-key") |
| 59 | if err != nil || len(catalog) != 1 || catalog[0].Model != "draft-model" { |
| 60 | t.Fatalf("draft catalog: %v, %v", catalog, err) |
| 61 | } |
| 62 | if err := a.TestProviderModel(p, "draft-model", "draft-key"); err != nil { |
| 63 | t.Fatal(err) |
| 64 | } |
| 65 | if err := a.TestProviderModel(p, "unlisted-model", "draft-key"); err == nil { |
| 66 | t.Fatal("unlisted model should be rejected before network access") |
| 67 | } |
| 68 | afterCache, _ := os.ReadFile(cachePath) |
| 69 | if string(beforeCache) != string(afterCache) { |
| 70 | t.Fatal("draft credentials polluted the saved capability cache") |
| 71 | } |
| 72 | if len(paths) != 2 { |
| 73 | t.Fatalf("unexpected requests: %v", paths) |
| 74 | } |
| 75 | if os.Getenv(keyEnv) != "process-key" || config.CredentialStoreRevision() != revision { |
| 76 | t.Fatal("probe changed process environment or stored credentials") |
| 77 | } |
| 78 | cfg, err := config.LoadForRootWithoutCredentialsReadOnly("") |
| 79 | if err != nil { |
| 80 | t.Fatal(err) |
| 81 | } |
| 82 | if _, exists := cfg.Provider(p.Name); exists { |
| 83 | t.Fatal("probe persisted the draft provider") |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | func TestProviderModelProbeReportsHTTPFailure(t *testing.T) { |
| 88 | isolateDesktopUserDirs(t) |
| 89 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 90 | http.Error(w, `{"error":{"message":"invalid credential"}}`, http.StatusUnauthorized) |
| 91 | })) |
| 92 | defer srv.Close() |
| 93 | err := NewApp().TestProviderModel(ProviderView{Name: "probe", Kind: "openai", BaseURL: srv.URL, Models: []string{"model"}}, "model", "bad-key") |
| 94 | if err == nil { |
| 95 | t.Fatal("failed authentication reported as successful") |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | func TestConfiguredModelInfoUsesPerModelOverrides(t *testing.T) { |
| 100 | vision := true |
| 101 | cfg := &config.Config{Providers: []config.ProviderEntry{{ |
| 102 | Name: "custom", Kind: "openai", BaseURL: "https://example.test/v1", |
| 103 | Models: []string{"model-a", "model-b"}, ContextWindow: 32768, |
| 104 | ModelOverrides: map[string]config.ProviderModelOverride{"model-b": {ContextWindow: 65536, Vision: &vision}}, |
| 105 | }}} |
| 106 | first := configuredModelInfo(cfg, "custom", "model-a", false) |
| 107 | second := configuredModelInfo(cfg, "custom", "model-b", true) |
| 108 | if first.ContextWindow != 32768 || first.Vision || second.ContextWindow != 65536 || !second.Vision || !second.Current { |
| 109 | t.Fatalf("model menu metadata disagrees with per-model configuration: %+v, %+v", first, second) |
| 110 | } |
| 111 | } |
| 112 |