| 1 | package openai |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | "testing" |
| 6 | |
| 7 | "reasonix/internal/provider" |
| 8 | ) |
| 9 | |
| 10 | const ( |
| 11 | wantTokenRhythmChat = "https://tokenrhythm.studio/v1/chat/completions" |
| 12 | wantTokenRhythmModels = "https://tokenrhythm.studio/v1/models" |
| 13 | ) |
| 14 | |
| 15 | func TestCanonicalTokenRhythmEndpoints(t *testing.T) { |
| 16 | t.Parallel() |
| 17 | |
| 18 | tests := []struct { |
| 19 | name string |
| 20 | raw string |
| 21 | ok bool |
| 22 | }{ |
| 23 | {name: "v1 base", raw: "https://tokenrhythm.studio/v1", ok: true}, |
| 24 | {name: "v1 chat", raw: "https://tokenrhythm.studio/v1/chat/completions", ok: true}, |
| 25 | {name: "v1 models", raw: "https://tokenrhythm.studio/v1/models", ok: true}, |
| 26 | {name: "missing v1 root", raw: "https://tokenrhythm.studio", ok: true}, |
| 27 | {name: "missing v1 slash root", raw: "https://tokenrhythm.studio/", ok: true}, |
| 28 | {name: "missing v1 chat", raw: "https://tokenrhythm.studio/chat/completions", ok: true}, |
| 29 | {name: "missing v1 models", raw: "https://tokenrhythm.studio/models", ok: true}, |
| 30 | {name: "double v1 base", raw: "https://tokenrhythm.studio/v1/v1", ok: true}, |
| 31 | {name: "double v1 chat", raw: "https://tokenrhythm.studio/v1/v1/chat/completions", ok: true}, |
| 32 | {name: "double v1 models", raw: "https://tokenrhythm.studio/v1/v1/models", ok: true}, |
| 33 | {name: "repeated chat suffix", raw: "https://tokenrhythm.studio/v1/chat/completions/chat/completions", ok: true}, |
| 34 | {name: "repeated chat suffix without v1", raw: "https://tokenrhythm.studio/chat/completions/chat/completions", ok: true}, |
| 35 | {name: "repeated chat suffix with double v1", raw: "https://tokenrhythm.studio/v1/v1/chat/completions/chat/completions", ok: true}, |
| 36 | {name: "repeated models suffix", raw: "https://tokenrhythm.studio/v1/models/models", ok: true}, |
| 37 | {name: "repeated models suffix without v1", raw: "https://tokenrhythm.studio/models/models", ok: true}, |
| 38 | {name: "repeated models suffix with double v1", raw: "https://tokenrhythm.studio/v1/v1/models/models", ok: true}, |
| 39 | {name: "uppercase hostname", raw: "https://TOKENRHYTHM.STUDIO/v1", ok: true}, |
| 40 | {name: "mixed-case hostname chat", raw: "https://TokenRhythm.Studio/v1/chat/completions", ok: true}, |
| 41 | {name: "explicit 443", raw: "https://tokenrhythm.studio:443/v1", ok: true}, |
| 42 | {name: "explicit 443 models", raw: "https://tokenrhythm.studio:443/v1/models", ok: true}, |
| 43 | {name: "trailing slash base", raw: "https://tokenrhythm.studio/v1/", ok: true}, |
| 44 | {name: "trailing slash chat", raw: "https://tokenrhythm.studio/v1/chat/completions/", ok: true}, |
| 45 | {name: "trailing slash models", raw: "https://tokenrhythm.studio/v1/models/", ok: true}, |
| 46 | {name: "whitespace around official url", raw: " https://tokenrhythm.studio/v1 ", ok: true}, |
| 47 | |
| 48 | {name: "api subdomain", raw: "https://api.tokenrhythm.studio/v1", ok: false}, |
| 49 | {name: "suffix domain", raw: "https://tokenrhythm.studio.example.com/v1", ok: false}, |
| 50 | {name: "third-party gateway", raw: "https://gateway.example.com/v1", ok: false}, |
| 51 | {name: "http scheme", raw: "http://tokenrhythm.studio/v1", ok: false}, |
| 52 | {name: "non-443 port", raw: "https://tokenrhythm.studio:8443/v1", ok: false}, |
| 53 | {name: "userinfo", raw: "https://user@tokenrhythm.studio/v1", ok: false}, |
| 54 | {name: "userinfo password", raw: "https://user:pass@tokenrhythm.studio/v1", ok: false}, |
| 55 | {name: "query", raw: "https://tokenrhythm.studio/v1?foo=1", ok: false}, |
| 56 | {name: "empty query", raw: "https://tokenrhythm.studio/v1?", ok: false}, |
| 57 | {name: "fragment", raw: "https://tokenrhythm.studio/v1#frag", ok: false}, |
| 58 | {name: "empty fragment", raw: "https://tokenrhythm.studio/v1#", ok: false}, |
| 59 | {name: "escaped path slash", raw: "https://tokenrhythm.studio/v1%2Fchat%2Fcompletions", ok: false}, |
| 60 | {name: "escaped v1", raw: "https://tokenrhythm.studio/%76%31", ok: false}, |
| 61 | {name: "invalid url", raw: "://tokenrhythm.studio/v1", ok: false}, |
| 62 | {name: "relative url", raw: "/v1/chat/completions", ok: false}, |
| 63 | {name: "missing scheme", raw: "tokenrhythm.studio/v1", ok: false}, |
| 64 | {name: "unknown path", raw: "https://tokenrhythm.studio/openai", ok: false}, |
| 65 | {name: "triple v1", raw: "https://tokenrhythm.studio/v1/v1/v1", ok: false}, |
| 66 | {name: "chat suffix repeated twice", raw: "https://tokenrhythm.studio/v1/chat/completions/chat/completions/chat/completions", ok: false}, |
| 67 | {name: "anthropic messages", raw: "https://tokenrhythm.studio/v1/messages", ok: false}, |
| 68 | {name: "embeddings", raw: "https://tokenrhythm.studio/v1/embeddings", ok: false}, |
| 69 | {name: "trailing-dot hostname", raw: "https://tokenrhythm.studio./v1", ok: false}, |
| 70 | {name: "empty", raw: "", ok: false}, |
| 71 | {name: "garbage", raw: "not a url", ok: false}, |
| 72 | } |
| 73 | |
| 74 | for _, tt := range tests { |
| 75 | t.Run(tt.name, func(t *testing.T) { |
| 76 | t.Parallel() |
| 77 | gotChat, chatOK := canonicalTokenRhythmChatURL(tt.raw) |
| 78 | gotModels, modelsOK := CanonicalTokenRhythmModelsURL(tt.raw) |
| 79 | if chatOK != tt.ok || modelsOK != tt.ok { |
| 80 | t.Fatalf("ok chat=%v models=%v, want %v", chatOK, modelsOK, tt.ok) |
| 81 | } |
| 82 | if !tt.ok { |
| 83 | if gotChat != "" || gotModels != "" { |
| 84 | t.Fatalf("rewrote unknown input %q to chat=%q models=%q", tt.raw, gotChat, gotModels) |
| 85 | } |
| 86 | return |
| 87 | } |
| 88 | if gotChat != wantTokenRhythmChat { |
| 89 | t.Fatalf("chat = %q, want %q", gotChat, wantTokenRhythmChat) |
| 90 | } |
| 91 | if gotModels != wantTokenRhythmModels { |
| 92 | t.Fatalf("models = %q, want %q", gotModels, wantTokenRhythmModels) |
| 93 | } |
| 94 | if strings.Count(gotChat, "/v1") != 1 || strings.Count(gotChat, "/chat/completions") != 1 { |
| 95 | t.Fatalf("chat %q is not a single canonical endpoint", gotChat) |
| 96 | } |
| 97 | if strings.Count(gotModels, "/v1") != 1 || strings.Count(gotModels, "/models") != 1 { |
| 98 | t.Fatalf("models %q is not a single canonical endpoint", gotModels) |
| 99 | } |
| 100 | }) |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | func TestNewCanonicalizesTokenRhythmChatURLs(t *testing.T) { |
| 105 | t.Parallel() |
| 106 | |
| 107 | tests := []struct { |
| 108 | name string |
| 109 | cfg provider.Config |
| 110 | want string |
| 111 | }{ |
| 112 | { |
| 113 | name: "request_url wins over legacy chat_url and base_url", |
| 114 | cfg: provider.Config{ |
| 115 | Name: "律动", |
| 116 | BaseURL: "https://tokenrhythm.studio/v1/v1", |
| 117 | Model: "test-model", |
| 118 | Extra: map[string]any{ |
| 119 | "chat_url": "https://legacy.example.com/chat/completions/", |
| 120 | "request_url": "https://exact.example.com/custom/?token=1", |
| 121 | }, |
| 122 | }, |
| 123 | want: "https://exact.example.com/custom/?token=1", |
| 124 | }, |
| 125 | { |
| 126 | name: "legacy chat_url wins over base_url", |
| 127 | cfg: provider.Config{ |
| 128 | Name: "other", |
| 129 | BaseURL: "https://base.example.com/v1", |
| 130 | Model: "test-model", |
| 131 | Extra: map[string]any{ |
| 132 | "chat_url": "https://legacy.example.com/chat/completions/", |
| 133 | }, |
| 134 | }, |
| 135 | want: "https://legacy.example.com/chat/completions", |
| 136 | }, |
| 137 | { |
| 138 | name: "third-party base_url still appends chat completions", |
| 139 | cfg: provider.Config{ |
| 140 | Name: "other", |
| 141 | BaseURL: "https://base.example.com/v1", |
| 142 | Model: "test-model", |
| 143 | }, |
| 144 | want: "https://base.example.com/v1/chat/completions", |
| 145 | }, |
| 146 | { |
| 147 | name: "request_url tokenrhythm typo is repaired", |
| 148 | cfg: provider.Config{ |
| 149 | Name: "custom-gateway", |
| 150 | BaseURL: "https://example.invalid/v1", |
| 151 | Model: "test-model", |
| 152 | Extra: map[string]any{ |
| 153 | "chat_url": "https://legacy.example.com/chat/completions", |
| 154 | "request_url": "https://tokenrhythm.studio/v1/v1/chat/completions", |
| 155 | }, |
| 156 | }, |
| 157 | want: wantTokenRhythmChat, |
| 158 | }, |
| 159 | { |
| 160 | name: "legacy chat_url tokenrhythm typo is repaired", |
| 161 | cfg: provider.Config{ |
| 162 | Name: "律动", |
| 163 | BaseURL: "https://example.invalid/v1", |
| 164 | Model: "test-model", |
| 165 | Extra: map[string]any{ |
| 166 | "chat_url": "https://tokenrhythm.studio/chat/completions/", |
| 167 | }, |
| 168 | }, |
| 169 | want: wantTokenRhythmChat, |
| 170 | }, |
| 171 | { |
| 172 | name: "base_url tokenrhythm typo is repaired", |
| 173 | cfg: provider.Config{ |
| 174 | Name: "anything", |
| 175 | BaseURL: "https://tokenrhythm.studio/v1/chat/completions", |
| 176 | Model: "test-model", |
| 177 | }, |
| 178 | want: wantTokenRhythmChat, |
| 179 | }, |
| 180 | { |
| 181 | name: "missing v1 base_url is repaired", |
| 182 | cfg: provider.Config{ |
| 183 | Name: "律动", |
| 184 | BaseURL: "https://tokenrhythm.studio", |
| 185 | Model: "test-model", |
| 186 | }, |
| 187 | want: wantTokenRhythmChat, |
| 188 | }, |
| 189 | { |
| 190 | name: "third-party request_url keeps query and trailing slash", |
| 191 | cfg: provider.Config{ |
| 192 | Name: "other", |
| 193 | BaseURL: "https://tokenrhythm.studio/v1", |
| 194 | Model: "test-model", |
| 195 | Extra: map[string]any{ |
| 196 | "request_url": "https://exact.example.com/custom/?token=1", |
| 197 | }, |
| 198 | }, |
| 199 | want: "https://exact.example.com/custom/?token=1", |
| 200 | }, |
| 201 | { |
| 202 | name: "third-party request_url keeps trailing slash", |
| 203 | cfg: provider.Config{ |
| 204 | Name: "other", |
| 205 | BaseURL: "https://base.example.com/v1", |
| 206 | Model: "test-model", |
| 207 | Extra: map[string]any{ |
| 208 | "request_url": "https://exact.example.com/custom/", |
| 209 | }, |
| 210 | }, |
| 211 | want: "https://exact.example.com/custom/", |
| 212 | }, |
| 213 | { |
| 214 | name: "unknown official path is not guessed", |
| 215 | cfg: provider.Config{ |
| 216 | Name: "律动", |
| 217 | BaseURL: "https://tokenrhythm.studio/openai", |
| 218 | Model: "test-model", |
| 219 | }, |
| 220 | want: "https://tokenrhythm.studio/openai/chat/completions", |
| 221 | }, |
| 222 | { |
| 223 | name: "third-party query on official host is left alone", |
| 224 | cfg: provider.Config{ |
| 225 | Name: "律动", |
| 226 | BaseURL: "https://example.invalid/v1", |
| 227 | Model: "test-model", |
| 228 | Extra: map[string]any{ |
| 229 | "request_url": "https://tokenrhythm.studio/v1/chat/completions?trace=1", |
| 230 | }, |
| 231 | }, |
| 232 | want: "https://tokenrhythm.studio/v1/chat/completions?trace=1", |
| 233 | }, |
| 234 | } |
| 235 | |
| 236 | for _, tt := range tests { |
| 237 | t.Run(tt.name, func(t *testing.T) { |
| 238 | t.Parallel() |
| 239 | p, err := New(tt.cfg) |
| 240 | if err != nil { |
| 241 | t.Fatalf("New: %v", err) |
| 242 | } |
| 243 | got := p.(*client).chatURL |
| 244 | if got != tt.want { |
| 245 | t.Fatalf("chatURL = %q, want %q", got, tt.want) |
| 246 | } |
| 247 | }) |
| 248 | } |
| 249 | } |
| 250 |