diff --git a/src/backend/services/realtime/OpenAIRealtimeRelayService.test.ts b/src/backend/services/realtime/OpenAIRealtimeRelayService.test.ts index c193ebddd..2c0c10c4a 100644 --- a/src/backend/services/realtime/OpenAIRealtimeRelayService.test.ts +++ b/src/backend/services/realtime/OpenAIRealtimeRelayService.test.ts @@ -1074,6 +1074,72 @@ describe('OpenAIRealtimeRelayService relay', () => { expect((await c.closed).code).toBe(1008); }); + describe('when the plan lookup fails', () => { + type Resolvers = { subscriptionResolvers: unknown[] }; + // A resolver that throws makes the plan a provisional fallback. + const failing = () => { + throw new Error('billing unavailable'); + }; + const failLookups = (uuid: string) => { + const metering = server.services.metering; + (metering as unknown as Resolvers).subscriptionResolvers.unshift( + failing, + ); + metering.invalidateActorSubscription(uuid); + }; + afterEach(async () => { + const metering = server.services.metering; + const resolvers = (metering as unknown as Resolvers) + .subscriptionResolvers; + resolvers.splice(resolvers.indexOf(failing), 1); + // The fallback stays cached for its window otherwise. + const u = await server.stores.user.getByUsername(user.username); + metering.invalidateActorSubscription(u!.uuid); + }); + + it('asks the client to retry instead of reporting no subscription', async () => { + const u = await server.stores.user.getByUsername(user.username); + vi.mocked( + server.services.metering.getActorSubscription, + ).mockRestore(); + failLookups(u!.uuid); + const c = open(`${relayUrl}?auth_token=${user.token}`); + expect(await c.waitFor('error')).toMatchObject({ + error: { code: 'internal_error' }, + }); + expect((await c.closed).code).toBe(1011); + expect( + c.events.some( + (e) => + (e.error as { code?: string } | undefined)?.code === + 'subscription_required', + ), + ).toBe(false); + }); + + it('asks a session to retry instead of reporting no funds', async () => { + const u = await server.stores.user.getByUsername(user.username); + const c = open( + `${relayUrl}?model=gpt-realtime-mini&auth_token=${user.token}`, + ); + await c.waitFor('session.created'); + failLookups(u!.uuid); + vi.spyOn( + server.services.metering, + 'getAllowedUsage', + ).mockResolvedValue({ + remaining: 0, + monthUsageAllowance: 0, + addons: {}, + }); + c.ws.send(JSON.stringify({ type: 'hold.response' })); + expect(await c.waitFor('error')).toMatchObject({ + error: { code: 'internal_error' }, + }); + expect((await c.closed).code).toBe(1011); + }); + }); + it('forwards a normal stream without waiting for metering', async () => { const c = open(`${relayUrl}?auth_token=${user.token}`); await c.waitFor('session.created'); diff --git a/src/backend/services/realtime/OpenAIRealtimeRelayService.ts b/src/backend/services/realtime/OpenAIRealtimeRelayService.ts index 97721ff7e..6687276ca 100644 --- a/src/backend/services/realtime/OpenAIRealtimeRelayService.ts +++ b/src/backend/services/realtime/OpenAIRealtimeRelayService.ts @@ -812,6 +812,24 @@ export class OpenAIRealtimeRelayService extends PuterService { } session.close(closeCode, code); }; + // A plan or balance refusal decided on a plan whose lookup failed may + // not apply to this account, so the client is told to retry instead. + const refuse = async (code: string, message: string) => { + let provisional = true; + try { + ({ provisional } = + await this.services.metering.getActorSubscriptionStatus( + actor!, + )); + } catch (err) { + console.warn('[realtime] plan lookup failed', err); + } + if (provisional) { + reject('internal_error', 'Could not verify subscription', 1011); + } else { + reject(code, message); + } + }; if (this.#draining) { reject( @@ -896,11 +914,14 @@ export class OpenAIRealtimeRelayService extends PuterService { while (usageQueue.length) { const { rows, holds } = usageQueue[0]; if (rows.length) { - await this.#record(actor!, rows, () => - reject( - 'insufficient_funds', - 'No usage left for request', - ), + await this.#record( + actor!, + rows, + () => + void refuse( + 'insufficient_funds', + 'No usage left for request', + ), ); } await Promise.all(holds.map((hold) => releaseHold(hold))); @@ -938,7 +959,7 @@ export class OpenAIRealtimeRelayService extends PuterService { responseHoldAmount(billingModel), ); if (!taken) { - reject( + await refuse( 'insufficient_funds', 'No usage left for request', ); @@ -1159,7 +1180,7 @@ export class OpenAIRealtimeRelayService extends PuterService { } catch (err) { const status = (err as { statusCode?: number }).statusCode; if (status === 402 || status === 403) { - reject( + await refuse( 'subscription_required', 'Realtime conversations require a subscription', ); @@ -1177,7 +1198,7 @@ export class OpenAIRealtimeRelayService extends PuterService { responseHoldAmount(model), ); if (!probe) { - reject('insufficient_funds', 'No usage left for request'); + await refuse('insufficient_funds', 'No usage left for request'); return; } await probe.release(); diff --git a/src/docs/src/AI/realtime.md b/src/docs/src/AI/realtime.md index b89a54e70..27bd884b9 100644 --- a/src/docs/src/AI/realtime.md +++ b/src/docs/src/AI/realtime.md @@ -109,7 +109,7 @@ A new connection starts an empty conversation. To keep context, add earlier item ## Errors, billing, and limits -API errors arrive as `{ "type": "error", "error": { "code": "...", "message": "..." } }`. Handle socket errors and close frames too. Invalid, missing, or revoked credentials use `invalid_api_key`. If Puter can't check a credential, the error is `internal_error` and the session closes with 1011, so try again after a short, randomized delay. Other relay codes include `model_not_found`, `insufficient_funds`, `subscription_required`, `rate_limit_exceeded`, `invalid_event`, `connection_timeout`, `provider_unavailable`, `upstream_error`, and `server_restarting`. +API errors arrive as `{ "type": "error", "error": { "code": "...", "message": "..." } }`. Handle socket errors and close frames too. Invalid, missing, or revoked credentials use `invalid_api_key`. If Puter can't check a credential or the account's plan, the error is `internal_error` and the session closes with 1011, so try again after a short, randomized delay. Other relay codes include `model_not_found`, `insufficient_funds`, `subscription_required`, `rate_limit_exceeded`, `invalid_event`, `connection_timeout`, `provider_unavailable`, `upstream_error`, and `server_restarting`. Policy failures generally close with 1008, oversized client messages with 1009, upstream or setup failures with 1011, server restarts with 1012, and temporary server overload with 1013. Reconnect after 1011, 1012, or 1013. Authentication and model failures can arrive after the WebSocket handshake succeeds.