|
1 | 1 | import { beforeEach, describe, expect, it, vi } from "vitest"; |
| 2 | +import type { AuthService } from "../auth/auth"; |
2 | 3 | import type { |
3 | 4 | LlmGatewayAuth, |
4 | 5 | LlmGatewayEndpoints, |
@@ -43,8 +44,25 @@ function createService( |
43 | 44 | }; |
44 | 45 | const logger = { ...log, scope: () => log }; |
45 | 46 |
|
46 | | - const service = new LlmGatewayService(host, logger); |
47 | | - return { service, auth, endpoints, log }; |
| 47 | + const orgListeners: Array<(state: { currentOrgId: string | null }) => void> = |
| 48 | + []; |
| 49 | + const authService = { |
| 50 | + getState: () => ({ currentOrgId: "org-1" }), |
| 51 | + on: ( |
| 52 | + _event: string, |
| 53 | + listener: (state: { currentOrgId: string | null }) => void, |
| 54 | + ) => { |
| 55 | + orgListeners.push(listener); |
| 56 | + }, |
| 57 | + } as unknown as AuthService; |
| 58 | + const emitAuthState = (currentOrgId: string | null) => { |
| 59 | + for (const listener of orgListeners) { |
| 60 | + listener({ currentOrgId }); |
| 61 | + } |
| 62 | + }; |
| 63 | + |
| 64 | + const service = new LlmGatewayService(host, logger, authService); |
| 65 | + return { service, auth, endpoints, log, emitAuthState }; |
48 | 66 | } |
49 | 67 |
|
50 | 68 | const SUCCESS_BODY = { |
@@ -157,6 +175,137 @@ describe("LlmGatewayService.prompt", () => { |
157 | 175 | }); |
158 | 176 | }); |
159 | 177 |
|
| 178 | + it("surfaces a FastAPI bare-string detail as the error message", async () => { |
| 179 | + const fetchMock = vi.fn().mockResolvedValue( |
| 180 | + createJsonResponse( |
| 181 | + { |
| 182 | + detail: "OAuth application not authorized for product 'posthog_code'", |
| 183 | + }, |
| 184 | + 403, |
| 185 | + ), |
| 186 | + ); |
| 187 | + const { service } = createService(fetchMock); |
| 188 | + |
| 189 | + await expect( |
| 190 | + service.prompt([{ role: "user", content: "hi" }]), |
| 191 | + ).rejects.toMatchObject({ |
| 192 | + name: "LlmGatewayError", |
| 193 | + message: "OAuth application not authorized for product 'posthog_code'", |
| 194 | + type: "unknown_error", |
| 195 | + statusCode: 403, |
| 196 | + }); |
| 197 | + }); |
| 198 | + |
| 199 | + // The free-tier model gate's 403 body, as the gateway serves it. |
| 200 | + const MODEL_GATE_BODY = { |
| 201 | + error: { |
| 202 | + message: |
| 203 | + "Model 'claude-haiku-4-5' needs a paid PostHog plan. Models available on the free tier: @cf/zai-org/glm-5.2. Add a payment method to your organization to unlock all models. (rate_limit)", |
| 204 | + type: "permission_error", |
| 205 | + code: "model_gate", |
| 206 | + }, |
| 207 | + }; |
| 208 | + |
| 209 | + it("retries once on the free-tier model when the model gate 403s", async () => { |
| 210 | + const fetchMock = vi |
| 211 | + .fn() |
| 212 | + .mockResolvedValueOnce(createJsonResponse(MODEL_GATE_BODY, 403)) |
| 213 | + .mockResolvedValueOnce(createJsonResponse(SUCCESS_BODY)); |
| 214 | + const { service } = createService(fetchMock); |
| 215 | + |
| 216 | + const result = await service.prompt([{ role: "user", content: "hi" }], { |
| 217 | + model: "claude-haiku-4-5", |
| 218 | + }); |
| 219 | + |
| 220 | + expect(result.content).toBe("hello world"); |
| 221 | + expect(fetchMock).toHaveBeenCalledTimes(2); |
| 222 | + const firstBody = JSON.parse(fetchMock.mock.calls[0][1].body as string); |
| 223 | + const retryBody = JSON.parse(fetchMock.mock.calls[1][1].body as string); |
| 224 | + expect(firstBody.model).toBe("claude-haiku-4-5"); |
| 225 | + expect(retryBody.model).toBe("@cf/zai-org/glm-5.2"); |
| 226 | + }); |
| 227 | + |
| 228 | + it("routes straight to the free-tier model once the org is known unsubscribed", async () => { |
| 229 | + const fetchMock = vi |
| 230 | + .fn() |
| 231 | + .mockResolvedValueOnce(createJsonResponse(MODEL_GATE_BODY, 403)) |
| 232 | + .mockImplementation(async () => createJsonResponse(SUCCESS_BODY)); |
| 233 | + const { service } = createService(fetchMock); |
| 234 | + |
| 235 | + // First call learns "unsubscribed" from the gate's 403. |
| 236 | + await service.prompt([{ role: "user", content: "hi" }], { |
| 237 | + model: "claude-haiku-4-5", |
| 238 | + }); |
| 239 | + // Second call must not burn a round trip on the gate. |
| 240 | + await service.prompt([{ role: "user", content: "hi" }], { |
| 241 | + model: "claude-haiku-4-5", |
| 242 | + }); |
| 243 | + |
| 244 | + expect(fetchMock).toHaveBeenCalledTimes(3); |
| 245 | + const thirdBody = JSON.parse(fetchMock.mock.calls[2][1].body as string); |
| 246 | + expect(thirdBody.model).toBe("@cf/zai-org/glm-5.2"); |
| 247 | + }); |
| 248 | + |
| 249 | + it("forgets the learned subscription state when the organization changes", async () => { |
| 250 | + const fetchMock = vi |
| 251 | + .fn() |
| 252 | + .mockResolvedValueOnce(createJsonResponse(MODEL_GATE_BODY, 403)) |
| 253 | + .mockImplementation(async () => createJsonResponse(SUCCESS_BODY)); |
| 254 | + const { service, emitAuthState } = createService(fetchMock); |
| 255 | + |
| 256 | + await service.prompt([{ role: "user", content: "hi" }], { |
| 257 | + model: "claude-haiku-4-5", |
| 258 | + }); |
| 259 | + emitAuthState("org-2"); |
| 260 | + await service.prompt([{ role: "user", content: "hi" }], { |
| 261 | + model: "claude-haiku-4-5", |
| 262 | + }); |
| 263 | + |
| 264 | + const bodyAfterSwitch = JSON.parse( |
| 265 | + fetchMock.mock.calls[2][1].body as string, |
| 266 | + ); |
| 267 | + expect(bodyAfterSwitch.model).toBe("claude-haiku-4-5"); |
| 268 | + }); |
| 269 | + |
| 270 | + it("keeps the learned subscription state across same-org auth changes", async () => { |
| 271 | + const fetchMock = vi |
| 272 | + .fn() |
| 273 | + .mockResolvedValueOnce(createJsonResponse(MODEL_GATE_BODY, 403)) |
| 274 | + .mockImplementation(async () => createJsonResponse(SUCCESS_BODY)); |
| 275 | + const { service, emitAuthState } = createService(fetchMock); |
| 276 | + |
| 277 | + await service.prompt([{ role: "user", content: "hi" }], { |
| 278 | + model: "claude-haiku-4-5", |
| 279 | + }); |
| 280 | + emitAuthState("org-1"); |
| 281 | + await service.prompt([{ role: "user", content: "hi" }], { |
| 282 | + model: "claude-haiku-4-5", |
| 283 | + }); |
| 284 | + |
| 285 | + expect(fetchMock).toHaveBeenCalledTimes(3); |
| 286 | + const bodyAfterRefresh = JSON.parse( |
| 287 | + fetchMock.mock.calls[2][1].body as string, |
| 288 | + ); |
| 289 | + expect(bodyAfterRefresh.model).toBe("@cf/zai-org/glm-5.2"); |
| 290 | + }); |
| 291 | + |
| 292 | + it("does not retry non-gate 403s on the free-tier model", async () => { |
| 293 | + const fetchMock = vi |
| 294 | + .fn() |
| 295 | + .mockResolvedValue( |
| 296 | + createJsonResponse( |
| 297 | + { detail: "Product 'posthog_code' requires OAuth authentication" }, |
| 298 | + 403, |
| 299 | + ), |
| 300 | + ); |
| 301 | + const { service } = createService(fetchMock); |
| 302 | + |
| 303 | + await expect( |
| 304 | + service.prompt([{ role: "user", content: "hi" }]), |
| 305 | + ).rejects.toMatchObject({ statusCode: 403 }); |
| 306 | + expect(fetchMock).toHaveBeenCalledTimes(1); |
| 307 | + }); |
| 308 | + |
160 | 309 | it("throws a timeout LlmGatewayError when the request aborts via the internal timeout", async () => { |
161 | 310 | const fetchMock = vi.fn((_url: string, init?: RequestInit) => { |
162 | 311 | return new Promise<Response>((_resolve, reject) => { |
@@ -214,6 +363,40 @@ describe("LlmGatewayService.fetchUsage", () => { |
214 | 363 | statusCode: 503, |
215 | 364 | }); |
216 | 365 | }); |
| 366 | + |
| 367 | + it("parses the usage-based billing fields when present", async () => { |
| 368 | + const fetchMock = vi.fn().mockResolvedValue( |
| 369 | + createJsonResponse({ |
| 370 | + ...USAGE_BODY, |
| 371 | + ai_credits: { exhausted: true }, |
| 372 | + code_usage_subscribed: true, |
| 373 | + }), |
| 374 | + ); |
| 375 | + const { service } = createService(fetchMock); |
| 376 | + |
| 377 | + const usage = await service.fetchUsage(); |
| 378 | + |
| 379 | + expect(usage.ai_credits?.exhausted).toBe(true); |
| 380 | + expect(usage.code_usage_subscribed).toBe(true); |
| 381 | + }); |
| 382 | + |
| 383 | + it("feeds code_usage_subscribed into helper model routing", async () => { |
| 384 | + const fetchMock = vi |
| 385 | + .fn() |
| 386 | + .mockResolvedValueOnce( |
| 387 | + createJsonResponse({ ...USAGE_BODY, code_usage_subscribed: false }), |
| 388 | + ) |
| 389 | + .mockResolvedValue(createJsonResponse(SUCCESS_BODY)); |
| 390 | + const { service } = createService(fetchMock); |
| 391 | + |
| 392 | + await service.fetchUsage(); |
| 393 | + await service.prompt([{ role: "user", content: "hi" }], { |
| 394 | + model: "claude-haiku-4-5", |
| 395 | + }); |
| 396 | + |
| 397 | + const promptBody = JSON.parse(fetchMock.mock.calls[1][1].body as string); |
| 398 | + expect(promptBody.model).toBe("@cf/zai-org/glm-5.2"); |
| 399 | + }); |
217 | 400 | }); |
218 | 401 |
|
219 | 402 | describe("LlmGatewayService.invalidatePlanCache", () => { |
|
0 commit comments