From 7b67ae7e39bd178e14104eecd4e14696a6eabdcb Mon Sep 17 00:00:00 2001 From: Rasad Regmi Date: Mon, 14 Sep 2026 11:23:23 +0545 Subject: [PATCH] fix(ai): send stable, non-sequential user identifiers to AI providers A precedence bug in the AI providers' identifier expression made every request send `user: ":undefined"` (the ternary bound the app-uid suffix to the whole `actor.user.id + actor.app?.uid` sum instead of just the suffix), or read `actor.user.id` on a missing user. The same expression also shipped the sequential internal user id, letting AI vendors correlate a single account across apps and sessions. All eight OpenAI-, Azure-, xAI-, Meta- and ZAI-style providers now build the identifier through one shared helper, `aiUserIdentifier()`: - `puter-[-]`: the random user UUID is always preserved in full; `maxLength` constrains only the app-bearing form - app attribution reads `effectiveApp`, so access-token requests name the issuing app instead of looking like direct user traffic - the app token is truncated to fit the budget, and omitted entirely when the remaining budget is below 8 chars, where a truncation could collide with another app's uid - nothing is sent for the system actor - Meta and ZAI keep a caller-supplied `safety_identifier` / `user_id` override, applied before the helper result `user` is deprecated by OpenAI; the SDK types direct callers to `safety_identifier` (abuse detection) and `prompt_cache_key` (cache-hit bucketing). The four OpenAI/Azure chat providers and MetaProvider now send `prompt_cache_key` as well, defaulting it to the same per-user identifier unless the caller supplies one; Azure's Grok branch drops both fields, matching its rejection of unknown args. The cap comment cites only verified limits: OpenAI's 64 for `safety_identifier` (from the SDK types) and Z.AI's 6-128 for `user_id` (from Z.AI's docs); Meta and xAI document none, so none is claimed. The xAI image `#edit` path now carries the identifier like generation, and takes a named-options param so `user` cannot be transposed with the adjacent same-typed `aspectRatio`. Tests share a four-actor matrix (`user` / `user+app` / `access token` / `system`) with `assertActorMatrixIdentifiers()` across the six OpenAI-style suites; the helper has exact-string and boundary coverage (size caps, zero-budget and sub-base cases, no dangling separator, UUID never truncated, collision guard); the Azure Grok assertions run under a real user actor so they cannot pass vacuously. 212 provider-suite tests pass; typecheck and ESLint are clean. --- .../providers/azure/AzureChatProvider.test.ts | 79 ++++++++-- .../providers/azure/AzureChatProvider.ts | 25 +++- .../azure/AzureResponsesProvider.test.ts | 48 ++++-- .../providers/azure/AzureResponsesProvider.ts | 11 +- .../providers/meta/MetaProvider.test.ts | 43 +++++- .../ai-chat/providers/meta/MetaProvider.ts | 18 +-- .../OpenAiChatCompletionsProvider.test.ts | 48 +++++- .../openai/OpenAiChatCompletionsProvider.ts | 10 +- .../OpenAiChatResponsesProvider.test.ts | 27 +++- .../openai/OpenAiChatResponsesProvider.ts | 11 +- .../ai-chat/providers/zai/ZAIProvider.test.ts | 40 ++++- .../ai-chat/providers/zai/ZAIProvider.ts | 12 +- .../openai/OpenAiImageProvider.test.ts | 48 +++++- .../providers/openai/OpenAiImageProvider.ts | 4 +- .../providers/xai/XAIImageProvider.test.ts | 46 +++++- .../providers/xai/XAIImageProvider.ts | 42 ++++-- src/backend/drivers/integrationTestUtil.ts | 59 +++++++- .../drivers/util/aiUserIdentifier.test.ts | 137 ++++++++++++++++++ src/backend/drivers/util/aiUserIdentifier.ts | 62 ++++++++ 19 files changed, 682 insertions(+), 88 deletions(-) create mode 100644 src/backend/drivers/util/aiUserIdentifier.test.ts create mode 100644 src/backend/drivers/util/aiUserIdentifier.ts diff --git a/src/backend/drivers/ai-chat/providers/azure/AzureChatProvider.test.ts b/src/backend/drivers/ai-chat/providers/azure/AzureChatProvider.test.ts index 66cc317f4e..ded979e1a6 100644 --- a/src/backend/drivers/ai-chat/providers/azure/AzureChatProvider.test.ts +++ b/src/backend/drivers/ai-chat/providers/azure/AzureChatProvider.test.ts @@ -46,11 +46,15 @@ import { type MockInstance, } from 'vitest'; -import { SYSTEM_ACTOR } from '../../../../core/actor.js'; +import { SYSTEM_ACTOR, makeActor } from '../../../../core/actor.js'; import type { MeteringService } from '../../../../services/metering/MeteringService.js'; import { PuterServer } from '../../../../server.js'; import { setupTestServer } from '../../../../testUtil.js'; -import { withTestActor } from '../../../integrationTestUtil.js'; +import { + assertActorMatrixIdentifiers, + makeActorMatrix, + withTestActor, +} from '../../../integrationTestUtil.js'; import { AIChatStream } from '../../utils/Streaming.js'; import { AzureChatProvider } from './AzureChatProvider.js'; import { AZURE_MODELS } from './models.js'; @@ -389,31 +393,82 @@ describe('AzureChatProvider.complete request shape', () => { const provider = makeProvider(); createMock.mockResolvedValueOnce(okCompletion); - await withTestActor(() => - provider.complete({ - model: 'gpt-4o', - messages: [{ role: 'user', content: 'hi' }], - }), + await withTestActor( + () => + provider.complete({ + model: 'gpt-4o', + messages: [{ role: 'user', content: 'hi' }], + }), + makeActor({ user: { id: 42, uuid: 'u42', username: 'alice' } }), ); const [args] = createMock.mock.calls[0]!; + expect(args.user).toBe('puter-u42'); expect('safety_identifier' in args).toBe(true); expect(args.safety_identifier).toBe(args.user); }); - it('strips safety_identifier for Grok deployments, which 400 on unknown args', async () => { + it('sends the actor uuid and effective app uid as user/safety_identifier', async () => { + const provider = makeProvider(); + createMock.mockResolvedValue(okCompletion); + + for (const actor of makeActorMatrix()) { + await withTestActor( + () => + provider.complete({ + model: 'gpt-4o', + messages: [{ role: 'user', content: 'hello' }], + }), + actor, + ); + } + + assertActorMatrixIdentifiers(createMock.mock.calls, [ + 'safety_identifier', + 'prompt_cache_key', + ]); + }); + + it('forwards a caller-supplied prompt_cache_key instead of the derived identifier', async () => { const provider = makeProvider(); createMock.mockResolvedValueOnce(okCompletion); - await withTestActor(() => - provider.complete({ - model: 'grok-4-20-non-reasoning', - messages: [{ role: 'user', content: 'hi' }], + await withTestActor( + () => + provider.complete({ + model: 'gpt-4o', + messages: [{ role: 'user', content: 'hi' }], + prompt_cache_key: 'caller-key', + }), + makeActor({ user: { id: 42, uuid: 'u42', username: 'alice' } }), + ); + + const [args] = createMock.mock.calls[0]!; + expect(args.prompt_cache_key).toBe('caller-key'); + expect(args.safety_identifier).toBe('puter-u42'); + }); + + it('strips safety_identifier/prompt_cache_key for Grok deployments, which 400 on unknown args', async () => { + const provider = makeProvider(); + createMock.mockResolvedValueOnce(okCompletion); + + // Runs under a real user actor so `user` would be present; only the + // Grok branch may drop `safety_identifier`/`prompt_cache_key`. + await withTestActor( + () => + provider.complete({ + model: 'grok-4-20-non-reasoning', + messages: [{ role: 'user', content: 'hi' }], + }), + makeActor({ + user: { id: 42, uuid: 'u42', username: 'alice' }, }), ); const [args] = createMock.mock.calls[0]!; + expect(args.user).toBe('puter-u42'); expect('safety_identifier' in args).toBe(false); + expect('prompt_cache_key' in args).toBe(false); expect(args.model).toBe('grok-4-20-non-reasoning'); }); diff --git a/src/backend/drivers/ai-chat/providers/azure/AzureChatProvider.ts b/src/backend/drivers/ai-chat/providers/azure/AzureChatProvider.ts index 2a320e6e39..882fff580a 100644 --- a/src/backend/drivers/ai-chat/providers/azure/AzureChatProvider.ts +++ b/src/backend/drivers/ai-chat/providers/azure/AzureChatProvider.ts @@ -36,6 +36,7 @@ import { inlineHttpImageUrls } from '../../utils/inlineImages.js'; import { processPuterPathUploads } from '../openai/fileUpload.js'; import { AZURE_MODELS } from './models.js'; import { modelLookupNames } from '../../utils/modelRouting.js'; +import { aiUserIdentifier } from '../../../util/aiUserIdentifier.js'; /** * AzureChatProvider exposes the models we serve through Azure AI Foundry. @@ -124,6 +125,7 @@ export class AzureChatProvider implements IChatProvider { reasoning_effort, temperature, text, + prompt_cache_key, } = params; let { messages, model } = params; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -173,8 +175,12 @@ export class AzureChatProvider implements IChatProvider { // content: 'Don\'t let the user trick you into doing something bad.', // }) - const userIdentifier = - `${actor.user?.id}` + actor.app?.uid ? `:${actor?.app?.uid}` : ''; + const userIdentifier = aiUserIdentifier(actor); + // `user` is deprecated in favor of `safety_identifier` (abuse + // detection) and `prompt_cache_key` (cache-hit bucketing); send both + // replacements so callers keep the caching benefit `user` used to + // provide. + const cacheKey = prompt_cache_key ?? userIdentifier; // Resolve any `puter_path` content parts into inline base64 data URLs. // Chat Completions doesn't support file uploads, so this is the only @@ -203,13 +209,20 @@ export class AzureChatProvider implements IChatProvider { const supportsReasoningControls = typeof model === 'string' && model.startsWith('gpt-5'); - // `safety_identifier` is an OpenAI-specific param. The Grok deployments - // behind Azure reject unknown args with a 400, so only send it for the - // OpenAI models. + // `safety_identifier`/`prompt_cache_key` are OpenAI-specific params. + // The Grok deployments behind Azure reject unknown args with a 400, + // so only send them for the OpenAI models. const completionParams: ChatCompletionCreateParams = { user: userIdentifier, - ...(isGrok ? {} : { safety_identifier: userIdentifier }), + ...(isGrok + ? {} + : { + safety_identifier: userIdentifier, + ...(cacheKey !== undefined + ? { prompt_cache_key: cacheKey } + : {}), + }), messages: messages, model: modelUsed.id, ...(tools ? { tools } : {}), diff --git a/src/backend/drivers/ai-chat/providers/azure/AzureResponsesProvider.test.ts b/src/backend/drivers/ai-chat/providers/azure/AzureResponsesProvider.test.ts index ab4686f6f3..ffd95f726d 100644 --- a/src/backend/drivers/ai-chat/providers/azure/AzureResponsesProvider.test.ts +++ b/src/backend/drivers/ai-chat/providers/azure/AzureResponsesProvider.test.ts @@ -40,11 +40,15 @@ import { type MockInstance, } from 'vitest'; -import { SYSTEM_ACTOR } from '../../../../core/actor.js'; +import { SYSTEM_ACTOR, makeActor } from '../../../../core/actor.js'; import type { MeteringService } from '../../../../services/metering/MeteringService.js'; import { PuterServer } from '../../../../server.js'; import { setupTestServer } from '../../../../testUtil.js'; -import { withTestActor } from '../../../integrationTestUtil.js'; +import { + assertActorMatrixIdentifiers, + makeActorMatrix, + withTestActor, +} from '../../../integrationTestUtil.js'; import { AIChatStream } from '../../utils/Streaming.js'; import { AzureResponsesProvider } from './AzureResponsesProvider.js'; import { AZURE_MODELS } from './models.js'; @@ -206,17 +210,19 @@ describe('AzureResponsesProvider.complete argument validation', () => { // -- Request shape --------------------------------------------------- describe('AzureResponsesProvider.complete request shape', () => { - it('sends messages as `input`, renames max_tokens, and always sets safety_identifier', async () => { + it('sends messages as `input`, renames max_tokens, and sets safety_identifier from the actor', async () => { const provider = makeProvider(); responsesCreateMock.mockResolvedValueOnce(okResponse); - await withTestActor(() => - provider.complete({ - model: 'gpt-5.3-codex', - messages: [{ role: 'user', content: 'hello' }], - max_tokens: 256, - temperature: 0.3, - }), + await withTestActor( + () => + provider.complete({ + model: 'gpt-5.3-codex', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 256, + temperature: 0.3, + }), + makeActor({ user: { id: 42, uuid: 'u42', username: 'alice' } }), ); const [args] = responsesCreateMock.mock.calls[0]!; @@ -224,9 +230,31 @@ describe('AzureResponsesProvider.complete request shape', () => { expect(args.input).toEqual([{ role: 'user', content: 'hello' }]); expect(args.max_output_tokens).toBe(256); expect(args.temperature).toBe(0.3); + expect(args.user).toBe('puter-u42'); expect(args.safety_identifier).toBe(args.user); }); + it('sends the actor uuid and effective app uid as user/safety_identifier', async () => { + const provider = makeProvider(); + responsesCreateMock.mockResolvedValue(okResponse); + + for (const actor of makeActorMatrix()) { + await withTestActor( + () => + provider.complete({ + model: 'gpt-5.3-codex', + messages: [{ role: 'user', content: 'hello' }], + }), + actor, + ); + } + + assertActorMatrixIdentifiers(responsesCreateMock.mock.calls, [ + 'safety_identifier', + 'prompt_cache_key', + ]); + }); + it('resolves an alias against the unrestricted catalog', async () => { const provider = makeProvider(); responsesCreateMock.mockResolvedValueOnce(okResponse); diff --git a/src/backend/drivers/ai-chat/providers/azure/AzureResponsesProvider.ts b/src/backend/drivers/ai-chat/providers/azure/AzureResponsesProvider.ts index 9e49330add..9806bdd8c8 100644 --- a/src/backend/drivers/ai-chat/providers/azure/AzureResponsesProvider.ts +++ b/src/backend/drivers/ai-chat/providers/azure/AzureResponsesProvider.ts @@ -32,6 +32,7 @@ import { processPuterPathUploads } from '../openai/fileUpload.js'; import { AZURE_MODELS } from './models.js'; import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js'; import { modelLookupNames } from '../../utils/modelRouting.js'; +import { aiUserIdentifier } from '../../../util/aiUserIdentifier.js'; /** * AzureResponsesProvider serves the Responses-API-only models we expose through @@ -141,8 +142,12 @@ export class AzureResponsesProvider implements IChatProvider { (m) => m.id === this.getDefaultModel(), )!; - const userIdentifier = - actor?.user.id + actor?.app?.uid ? `:${actor?.app?.uid}` : ''; + const userIdentifier = aiUserIdentifier(actor); + // `user` is deprecated in favor of `safety_identifier` (abuse + // detection) and `prompt_cache_key` (cache-hit bucketing); default + // the latter to the same identifier when the caller didn't supply + // one, so callers keep the caching benefit `user` used to provide. + const cacheKey = prompt_cache_key ?? userIdentifier; // Resolve any `puter_path` content parts into inline base64 data URLs // before the Responses API sees them. @@ -206,7 +211,7 @@ export class AzureResponsesProvider implements IChatProvider { ...(instructions !== undefined ? { instructions } : {}), ...(metadata !== undefined ? { metadata } : {}), ...(prompt !== undefined ? { prompt } : {}), - ...(prompt_cache_key !== undefined ? { prompt_cache_key } : {}), + ...(cacheKey !== undefined ? { prompt_cache_key: cacheKey } : {}), ...(prompt_cache_retention !== undefined ? { prompt_cache_retention } : {}), diff --git a/src/backend/drivers/ai-chat/providers/meta/MetaProvider.test.ts b/src/backend/drivers/ai-chat/providers/meta/MetaProvider.test.ts index 1af959b422..56145019be 100644 --- a/src/backend/drivers/ai-chat/providers/meta/MetaProvider.test.ts +++ b/src/backend/drivers/ai-chat/providers/meta/MetaProvider.test.ts @@ -43,7 +43,7 @@ import { } from 'vitest'; import type { Actor } from '../../../../core/actor.js'; -import { SYSTEM_ACTOR } from '../../../../core/actor.js'; +import { SYSTEM_ACTOR, makeActor } from '../../../../core/actor.js'; import type { MeteringService } from '../../../../services/metering/MeteringService.js'; import { PuterServer } from '../../../../server.js'; import { setupTestServer } from '../../../../testUtil.js'; @@ -361,18 +361,38 @@ describe('MetaProvider.complete request shape', () => { it('derives safety_identifier from the actor and truncates it to 64 chars', async () => { createMock.mockResolvedValueOnce(OK_COMPLETION); - const userActor: Actor = { + const userActor = makeActor({ user: { id: 42, uuid: 'u42', username: 'alice' }, app: { id: 7, uid: 'a'.repeat(80) }, - }; + }); await complete(makeProvider(), {}, userActor); const identifier = createMock.mock.calls[0]![0].safety_identifier; - expect(identifier.startsWith('puter-42-a')).toBe(true); + expect(identifier.startsWith('puter-u42-a')).toBe(true); expect(identifier.length).toBe(64); }); + it('attributes the app through effectiveApp for access-token actors', async () => { + createMock.mockResolvedValueOnce(OK_COMPLETION); + const tokenActor = makeActor({ + user: { id: 42, uuid: 'u42', username: 'alice' }, + accessToken: { + uid: 'tok-1', + issuer: makeActor({ + user: { id: 42, uuid: 'u42', username: 'alice' }, + app: { id: 7, uid: 'app-abc' }, + }), + }, + }); + + await complete(makeProvider(), {}, tokenActor); + + expect(createMock.mock.calls[0]![0].safety_identifier).toBe( + 'puter-u42-app-abc', + ); + }); + it('prefers an explicit custom.safety_identifier over the actor-derived one', async () => { createMock.mockResolvedValueOnce(OK_COMPLETION); const userActor: Actor = { user: { id: 42, uuid: 'u42' } }; @@ -386,12 +406,25 @@ describe('MetaProvider.complete request shape', () => { ); }); - it('omits safety_identifier for the system actor (no user.id)', async () => { + it('omits safety_identifier for the system actor', async () => { createMock.mockResolvedValueOnce(OK_COMPLETION); await complete(makeProvider()); expect('safety_identifier' in createMock.mock.calls[0]![0]).toBe(false); }); + it('defaults prompt_cache_key to the actor identifier when not supplied', async () => { + createMock.mockResolvedValueOnce(OK_COMPLETION); + const userActor = makeActor({ + user: { id: 42, uuid: 'u42', username: 'alice' }, + }); + + await complete(makeProvider(), {}, userActor); + + expect(createMock.mock.calls[0]![0].prompt_cache_key).toBe( + 'puter-u42', + ); + }); + it('only sets stream_options.include_usage when streaming', async () => { const provider = makeProvider(); createMock.mockResolvedValueOnce(OK_COMPLETION); diff --git a/src/backend/drivers/ai-chat/providers/meta/MetaProvider.ts b/src/backend/drivers/ai-chat/providers/meta/MetaProvider.ts index eef6c36252..1f0065a047 100644 --- a/src/backend/drivers/ai-chat/providers/meta/MetaProvider.ts +++ b/src/backend/drivers/ai-chat/providers/meta/MetaProvider.ts @@ -31,12 +31,10 @@ import { buildCostsOverride } from '../../utils/pricing.js'; import { processPuterPathUploads } from '../openai/fileUpload.js'; import { META_MODELS, MUSE_SPARK_DEFAULT_MODEL } from './models.js'; import { modelLookupNames } from '../../utils/modelRouting.js'; +import { aiUserIdentifier } from '../../../util/aiUserIdentifier.js'; const DEFAULT_API_BASE_URL = 'https://api.meta.ai/v1'; -// `safety_identifier` is capped at 64 characters by the Model API. -const SAFETY_IDENTIFIER_MAX_LENGTH = 64; - type MetaConfig = { apiBaseUrl?: string; apiKey: string; @@ -167,14 +165,12 @@ export class MetaProvider implements IChatProvider { ? 'in_memory' : prompt_cache_retention; + const userIdentifier = aiUserIdentifier(actor); const safetyIdentifier = - customParams.safety_identifier ?? - (actor?.user?.id - ? `puter-${actor.user.id}${actor.app?.uid ? `-${actor.app.uid}` : ''}`.slice( - 0, - SAFETY_IDENTIFIER_MAX_LENGTH, - ) - : undefined); + customParams.safety_identifier ?? userIdentifier; + // Default `prompt_cache_key` to the same identifier so requests still + // bucket by user when the caller doesn't set one explicitly. + const cacheKey = prompt_cache_key ?? userIdentifier; const completionParams = { messages, @@ -189,7 +185,7 @@ export class MetaProvider implements IChatProvider { ...(temperature !== undefined ? { temperature } : {}), ...(top_p !== undefined ? { top_p } : {}), ...(effort ? { reasoning_effort: effort } : {}), - ...(prompt_cache_key !== undefined ? { prompt_cache_key } : {}), + ...(cacheKey !== undefined ? { prompt_cache_key: cacheKey } : {}), ...(cacheRetention !== undefined ? { prompt_cache_retention: cacheRetention } : {}), diff --git a/src/backend/drivers/ai-chat/providers/openai/OpenAiChatCompletionsProvider.test.ts b/src/backend/drivers/ai-chat/providers/openai/OpenAiChatCompletionsProvider.test.ts index d84368db42..1faaea4e3d 100644 --- a/src/backend/drivers/ai-chat/providers/openai/OpenAiChatCompletionsProvider.test.ts +++ b/src/backend/drivers/ai-chat/providers/openai/OpenAiChatCompletionsProvider.test.ts @@ -42,11 +42,15 @@ import { type MockInstance, } from 'vitest'; -import { SYSTEM_ACTOR } from '../../../../core/actor.js'; +import { SYSTEM_ACTOR, makeActor } from '../../../../core/actor.js'; import type { MeteringService } from '../../../../services/metering/MeteringService.js'; import { PuterServer } from '../../../../server.js'; import { setupTestServer } from '../../../../testUtil.js'; -import { withTestActor } from '../../../integrationTestUtil.js'; +import { + assertActorMatrixIdentifiers, + makeActorMatrix, + withTestActor, +} from '../../../integrationTestUtil.js'; import { AIChatStream } from '../../utils/Streaming.js'; import { OPEN_AI_MODELS } from './models.js'; import { OpenAiChatProvider } from './OpenAiChatCompletionsProvider.js'; @@ -270,6 +274,46 @@ describe('OpenAiChatProvider.complete request shape', () => { expect(args.temperature).toBe(0.4); }); + it('sends the actor uuid and effective app uid as user/safety_identifier', async () => { + const { provider } = makeProvider(); + createMock.mockResolvedValue(baseCompletion); + + for (const actor of makeActorMatrix()) { + await withTestActor( + () => + provider.complete({ + model: 'gpt-5-nano', + messages: [{ role: 'user', content: 'hello' }], + }), + actor, + ); + } + + assertActorMatrixIdentifiers(createMock.mock.calls, [ + 'safety_identifier', + 'prompt_cache_key', + ]); + }); + + it('forwards a caller-supplied prompt_cache_key instead of the derived identifier', async () => { + const { provider } = makeProvider(); + createMock.mockResolvedValueOnce(baseCompletion); + + await withTestActor( + () => + provider.complete({ + model: 'gpt-5-nano', + messages: [{ role: 'user', content: 'hello' }], + prompt_cache_key: 'caller-key', + }), + makeActor({ user: { id: 42, uuid: 'u42', username: 'alice' } }), + ); + + const [args] = createMock.mock.calls[0]!; + expect(args.prompt_cache_key).toBe('caller-key'); + expect(args.safety_identifier).toBe('puter-u42'); + }); + it('resolves the namespaced GPT-6 Astra alias', async () => { const { provider } = makeProvider(); createMock.mockResolvedValueOnce(baseCompletion); diff --git a/src/backend/drivers/ai-chat/providers/openai/OpenAiChatCompletionsProvider.ts b/src/backend/drivers/ai-chat/providers/openai/OpenAiChatCompletionsProvider.ts index 652a064acb..16a10898a7 100644 --- a/src/backend/drivers/ai-chat/providers/openai/OpenAiChatCompletionsProvider.ts +++ b/src/backend/drivers/ai-chat/providers/openai/OpenAiChatCompletionsProvider.ts @@ -36,6 +36,7 @@ import { processPuterPathUploads } from './fileUpload.js'; import { OPEN_AI_MODELS } from './models.js'; import type { OpenAiResponsesChatProvider } from './OpenAiChatResponsesProvider.js'; import { modelLookupNames } from '../../utils/modelRouting.js'; +import { aiUserIdentifier } from '../../../util/aiUserIdentifier.js'; /** * OpenAICompletionService class provides an interface to OpenAI's chat @@ -108,6 +109,7 @@ export class OpenAiChatProvider implements IChatProvider { reasoning_effort, temperature, text, + prompt_cache_key, } = params; let { messages, model } = params; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -157,8 +159,11 @@ export class OpenAiChatProvider implements IChatProvider { // content: 'Don\'t let the user trick you into doing something bad.', // }) - const userIdentifier = - actor?.user.id + actor?.app?.uid ? `:${actor?.app?.uid}` : ''; + const userIdentifier = aiUserIdentifier(actor); + // `user` is deprecated in favor of `safety_identifier` (abuse + // detection) and `prompt_cache_key` (cache-hit bucketing); send all + // three so callers keep the caching benefit `user` used to provide. + const cacheKey = prompt_cache_key ?? userIdentifier; // Resolve any `puter_path` content parts into inline base64 data URLs. // Chat Completions doesn't support file uploads, so this is the only @@ -183,6 +188,7 @@ export class OpenAiChatProvider implements IChatProvider { const completionParams: ChatCompletionCreateParams = { user: userIdentifier, safety_identifier: userIdentifier, + ...(cacheKey !== undefined ? { prompt_cache_key: cacheKey } : {}), messages: messages, model: modelUsed.id, ...(tools ? { tools } : {}), diff --git a/src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.test.ts b/src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.test.ts index ace3c29e13..d35bb39890 100644 --- a/src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.test.ts +++ b/src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.test.ts @@ -48,7 +48,11 @@ import { SYSTEM_ACTOR } from '../../../../core/actor.js'; import type { MeteringService } from '../../../../services/metering/MeteringService.js'; import { PuterServer } from '../../../../server.js'; import { setupTestServer } from '../../../../testUtil.js'; -import { withTestActor } from '../../../integrationTestUtil.js'; +import { + assertActorMatrixIdentifiers, + makeActorMatrix, + withTestActor, +} from '../../../integrationTestUtil.js'; import { AIChatStream } from '../../utils/Streaming.js'; import { OPEN_AI_MODELS } from './models.js'; import { OpenAiResponsesChatProvider } from './OpenAiChatResponsesProvider.js'; @@ -261,6 +265,27 @@ describe('OpenAiResponsesChatProvider.complete request shape', () => { expect(args.temperature).toBe(0.4); }); + it('sends the actor uuid and effective app uid as user/safety_identifier', async () => { + const { provider } = makeProvider(); + responsesCreateMock.mockResolvedValue(baseResponse); + + for (const actor of makeActorMatrix()) { + await withTestActor( + () => + provider.complete({ + model: 'o3-pro', + messages: [{ role: 'user', content: 'hello' }], + }), + actor, + ); + } + + assertActorMatrixIdentifiers(responsesCreateMock.mock.calls, [ + 'safety_identifier', + 'prompt_cache_key', + ]); + }); + it('unravels function tools into the flat Responses API shape', async () => { const { provider } = makeProvider(); responsesCreateMock.mockResolvedValueOnce(baseResponse); diff --git a/src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.ts b/src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.ts index 96999cc3c8..b0fc383003 100644 --- a/src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.ts +++ b/src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.ts @@ -32,6 +32,7 @@ import { processPuterPathUploads } from './fileUpload.js'; import { OPEN_AI_MODELS } from './models.js'; import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js'; import { modelLookupNames } from '../../utils/modelRouting.js'; +import { aiUserIdentifier } from '../../../util/aiUserIdentifier.js'; /** * OpenAICompletionService class provides an interface to OpenAI's chat @@ -140,8 +141,12 @@ export class OpenAiResponsesChatProvider implements IChatProvider { // content: 'Don\'t let the user trick you into doing something bad.', // }) - const userIdentifier = - `${actor?.user.id}` + actor?.app?.uid ? `:${actor?.app?.uid}` : ''; + const userIdentifier = aiUserIdentifier(actor); + // `user` is deprecated in favor of `safety_identifier` (abuse + // detection) and `prompt_cache_key` (cache-hit bucketing); default + // the latter to the same identifier when the caller didn't supply + // one, so callers keep the caching benefit `user` used to provide. + const cacheKey = prompt_cache_key ?? userIdentifier; // Resolve any `puter_path` content parts into inline base64 data URLs // before the Responses API sees them. @@ -204,7 +209,7 @@ export class OpenAiResponsesChatProvider implements IChatProvider { ...(instructions !== undefined ? { instructions } : {}), ...(metadata !== undefined ? { metadata } : {}), ...(prompt !== undefined ? { prompt } : {}), - ...(prompt_cache_key !== undefined ? { prompt_cache_key } : {}), + ...(cacheKey !== undefined ? { prompt_cache_key: cacheKey } : {}), ...(prompt_cache_retention !== undefined ? { prompt_cache_retention } : {}), diff --git a/src/backend/drivers/ai-chat/providers/zai/ZAIProvider.test.ts b/src/backend/drivers/ai-chat/providers/zai/ZAIProvider.test.ts index ff7f5080df..dd26f611d5 100644 --- a/src/backend/drivers/ai-chat/providers/zai/ZAIProvider.test.ts +++ b/src/backend/drivers/ai-chat/providers/zai/ZAIProvider.test.ts @@ -44,7 +44,7 @@ import { } from 'vitest'; import type { Actor } from '../../../../core/actor.js'; -import { SYSTEM_ACTOR } from '../../../../core/actor.js'; +import { SYSTEM_ACTOR, makeActor } from '../../../../core/actor.js'; import type { MeteringService } from '../../../../services/metering/MeteringService.js'; import { PuterServer } from '../../../../server.js'; import { setupTestServer } from '../../../../testUtil.js'; @@ -326,10 +326,10 @@ describe('ZAIProvider.complete request shape', () => { const { provider } = makeProvider(); createMock.mockResolvedValueOnce(baseCompletion); - const userActor: Actor = { + const userActor = makeActor({ user: { id: 42, uuid: 'u42', username: 'alice' }, app: { id: 7, uid: 'app-uid' }, - }; + }); await withTestActor( () => @@ -341,7 +341,35 @@ describe('ZAIProvider.complete request shape', () => { ); const [args] = createMock.mock.calls[0]!; - expect(args.user_id).toBe('puter-42-app-uid'); + expect(args.user_id).toBe('puter-u42-app-uid'); + }); + + it('attributes the app through effectiveApp for access-token actors', async () => { + const { provider } = makeProvider(); + createMock.mockResolvedValueOnce(baseCompletion); + + const tokenActor = makeActor({ + user: { id: 42, uuid: 'u42', username: 'alice' }, + accessToken: { + uid: 'tok-1', + issuer: makeActor({ + user: { id: 42, uuid: 'u42', username: 'alice' }, + app: { id: 7, uid: 'app-uid' }, + }), + }, + }); + + await withTestActor( + () => + provider.complete({ + model: 'glm-4.6', + messages: [{ role: 'user', content: 'hi' }], + }), + tokenActor, + ); + + const [args] = createMock.mock.calls[0]!; + expect(args.user_id).toBe('puter-u42-app-uid'); }); it('prefers an explicit custom.user_id over the actor-derived one', async () => { @@ -366,7 +394,7 @@ describe('ZAIProvider.complete request shape', () => { expect(args.user_id).toBe('caller-supplied'); }); - it('omits user_id entirely for the system actor (no user.id)', async () => { + it('omits user_id entirely for the system actor', async () => { const { provider } = makeProvider(); createMock.mockResolvedValueOnce(baseCompletion); @@ -378,7 +406,7 @@ describe('ZAIProvider.complete request shape', () => { ); const [args] = createMock.mock.calls[0]!; - // SYSTEM_ACTOR has no user.id — provider should leave the key off. + // SYSTEM_ACTOR is excluded by isSystemActor() — the provider should leave the key off. expect('user_id' in args).toBe(false); }); diff --git a/src/backend/drivers/ai-chat/providers/zai/ZAIProvider.ts b/src/backend/drivers/ai-chat/providers/zai/ZAIProvider.ts index 910b3aa7ec..406bc6a860 100644 --- a/src/backend/drivers/ai-chat/providers/zai/ZAIProvider.ts +++ b/src/backend/drivers/ai-chat/providers/zai/ZAIProvider.ts @@ -25,6 +25,10 @@ import type { IChatProvider, ICompleteArguments } from '../../types.js'; import * as OpenAIUtil from '../../utils/OpenAIUtil.js'; import { ZAI_MODELS } from './models.js'; import { modelLookupNames } from '../../utils/modelRouting.js'; +import { aiUserIdentifier } from '../../../util/aiUserIdentifier.js'; + +// Z.AI documents `user_id` as 6-128 characters. +const USER_ID_MAX_LENGTH = 128; type ZAIConfig = { apiBaseUrl?: string; @@ -104,13 +108,7 @@ export class ZAIProvider implements IChatProvider { const customParams = asRecord(custom) as ZAICustomParams; const userId = - customParams.user_id ?? - (actor?.user?.id - ? `puter-${actor.user.id}${actor.app?.uid ? `-${actor.app.uid}` : ''}`.slice( - 0, - 128, - ) - : undefined); + customParams.user_id ?? aiUserIdentifier(actor, USER_ID_MAX_LENGTH); const completionParams: ChatCompletionCreateParams = { messages, diff --git a/src/backend/drivers/ai-image/providers/openai/OpenAiImageProvider.test.ts b/src/backend/drivers/ai-image/providers/openai/OpenAiImageProvider.test.ts index a83a0d3889..bfeae9456a 100644 --- a/src/backend/drivers/ai-image/providers/openai/OpenAiImageProvider.test.ts +++ b/src/backend/drivers/ai-image/providers/openai/OpenAiImageProvider.test.ts @@ -44,7 +44,11 @@ import { import type { MeteringService } from '../../../../services/metering/MeteringService.js'; import { PuterServer } from '../../../../server.js'; import { setupTestServer } from '../../../../testUtil.js'; -import { withTestActor } from '../../../integrationTestUtil.js'; +import { + assertActorMatrixIdentifiers, + makeActorMatrix, + withTestActor, +} from '../../../integrationTestUtil.js'; import { OPEN_AI_IMAGE_GENERATION_MODELS } from './models.js'; import { OpenAiImageProvider } from './OpenAiImageProvider.js'; @@ -251,6 +255,29 @@ describe('OpenAiImageProvider.generate output extraction', () => { }); }); +describe('OpenAiImageProvider.generate user identifier', () => { + it('sends the actor uuid and effective app uid as the user field', async () => { + const provider = makeProvider(); + generateMock.mockResolvedValue({ + data: [{ url: 'https://oai.example/img.png' }], + }); + + for (const actor of makeActorMatrix()) { + await withTestActor( + () => + provider.generate({ + model: 'gpt-image-1-mini', + prompt: 'hi', + ratio: { w: 1024, h: 1024 }, + }), + actor, + ); + } + + assertActorMatrixIdentifiers(generateMock.mock.calls); + }); +}); + // ── input_images / edit endpoint ─────────────────────────────────── describe('OpenAiImageProvider.generate input_images (edit endpoint)', () => { @@ -292,6 +319,25 @@ describe('OpenAiImageProvider.generate input_images (edit endpoint)', () => { expect(sent.image).toHaveLength(2); }); + it('sends the actor user identifier on the edit request like generation does', async () => { + const provider = makeProvider(); + editMock.mockResolvedValueOnce(editResponse); + + await withTestActor( + () => + provider.generate({ + model: 'gpt-image-1', + prompt: 'add a hat', + ratio: { w: 1024, h: 1024 }, + input_images: [PNG], + }), + makeActorMatrix()[1], + ); + + const sent = editMock.mock.calls[0]![0]; + expect(sent.user).toBe('puter-u42-app-abc'); + }); + it('meters an :input line at the image_input token rate when the edit response reports image tokens', async () => { const provider = makeProvider(); editMock.mockResolvedValueOnce(editResponse); diff --git a/src/backend/drivers/ai-image/providers/openai/OpenAiImageProvider.ts b/src/backend/drivers/ai-image/providers/openai/OpenAiImageProvider.ts index 89eba21bdb..6ec38d7e08 100644 --- a/src/backend/drivers/ai-image/providers/openai/OpenAiImageProvider.ts +++ b/src/backend/drivers/ai-image/providers/openai/OpenAiImageProvider.ts @@ -33,6 +33,7 @@ import type { import { OPEN_AI_IMAGE_GENERATION_MODELS } from './models.js'; import { fetchImageAsBase64, isHttpUrl } from '../../inputImage.js'; import { estimateTextTokens } from '../../../util/tokenEstimate.js'; +import { aiUserIdentifier } from '../../../util/aiUserIdentifier.js'; import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js'; interface OpenAIImageUsage { @@ -176,8 +177,7 @@ export class OpenAiImageProvider implements IImageProvider { } const actor = Context.get('actor'); - const userIdentifier = - actor?.user.id + actor?.app?.uid ? `:${actor?.app?.uid}` : ''; + const userIdentifier = aiUserIdentifier(actor); const estimatedPromptTokenCount = this.#estimatePromptTokenCount(prompt); diff --git a/src/backend/drivers/ai-image/providers/xai/XAIImageProvider.test.ts b/src/backend/drivers/ai-image/providers/xai/XAIImageProvider.test.ts index 6a42b8421a..67f2262614 100644 --- a/src/backend/drivers/ai-image/providers/xai/XAIImageProvider.test.ts +++ b/src/backend/drivers/ai-image/providers/xai/XAIImageProvider.test.ts @@ -43,7 +43,11 @@ import { import type { MeteringService } from '../../../../services/metering/MeteringService.js'; import { PuterServer } from '../../../../server.js'; import { setupTestServer } from '../../../../testUtil.js'; -import { withTestActor } from '../../../integrationTestUtil.js'; +import { + assertActorMatrixIdentifiers, + makeActorMatrix, + withTestActor, +} from '../../../integrationTestUtil.js'; import { XAI_IMAGE_GENERATION_MODELS } from './models.js'; import { XAIImageProvider } from './XAIImageProvider.js'; @@ -276,6 +280,26 @@ describe('XAIImageProvider.generate success path', () => { expect(out.costOverride).toBe(grok.costs['output:1k'] * 1_000_000); }); + it('sends the actor uuid and effective app uid as the user field', async () => { + const provider = makeProvider(); + generateMock.mockResolvedValue({ + data: [{ url: 'https://x.ai/img/abc' }], + }); + + for (const actor of makeActorMatrix()) { + await withTestActor( + () => + provider.generate({ + model: 'grok-imagine-image', + prompt: 'a small red dot', + }), + actor, + ); + } + + assertActorMatrixIdentifiers(generateMock.mock.calls); + }); + it('uses the 2k output rate when quality is "2k"', async () => { const provider = makeProvider(); generateMock.mockResolvedValueOnce({ @@ -361,6 +385,26 @@ describe('XAIImageProvider.generate input_images (edit endpoint)', () => { expect(body.image).toEqual({ type: 'image_url', url: PNG }); }); + it('sends the actor user identifier on the edit request like generation does', async () => { + const provider = makeProvider(); + postMock.mockResolvedValueOnce(editResponse); + + await withTestActor( + () => + provider.generate({ + model: 'grok-imagine-image', + prompt: 'add a hat', + input_images: [PNG], + }), + makeActorMatrix()[1], + ); + + const body = ( + postMock.mock.calls[0]![1] as { body: Record } + ).body; + expect(body.user).toBe('puter-u42-app-abc'); + }); + it('sends an array of image objects for multi-image edits and caps at 3', async () => { const provider = makeProvider(); postMock.mockResolvedValueOnce(editResponse); diff --git a/src/backend/drivers/ai-image/providers/xai/XAIImageProvider.ts b/src/backend/drivers/ai-image/providers/xai/XAIImageProvider.ts index 10dc292998..7a98edfa00 100644 --- a/src/backend/drivers/ai-image/providers/xai/XAIImageProvider.ts +++ b/src/backend/drivers/ai-image/providers/xai/XAIImageProvider.ts @@ -28,6 +28,7 @@ import type { import { XAI_IMAGE_GENERATION_MODELS } from './models.js'; import { HttpError } from '../../../../core/http/HttpError.js'; import { assertInputImageString } from '../../inputImage.js'; +import { aiUserIdentifier } from '../../../util/aiUserIdentifier.js'; const DEFAULT_MODEL = 'grok-imagine-image'; // xAI's Grok Imagine edit endpoint accepts up to 3 source images per request. @@ -94,8 +95,7 @@ export class XAIImageProvider implements IImageProvider { const aspectRatio = this.#aspectRatio(ratio); const actor = Context.get('actor'); - const userIdentifier = - actor?.user.id + actor?.app?.uid ? `:${actor?.app?.uid}` : ''; + const userIdentifier = aiUserIdentifier(actor); const outputPriceInCents = selectedModel.costs[`output:${resolution}`]; const mediaInputPriceInCents = selectedModel.costs.media_input ?? 0; @@ -116,14 +116,15 @@ export class XAIImageProvider implements IImageProvider { } const response = hasInputImages - ? await this.#edit( - selectedModel.id, + ? await this.#edit({ + modelId: selectedModel.id, prompt, - input_images!, - input_image_mime_type, + inputImages: input_images!, + mimeHint: input_image_mime_type, resolution, aspectRatio, - ) + user: userIdentifier, + }) : ((await this.#client.images.generate({ model: selectedModel.id, prompt, @@ -171,14 +172,24 @@ export class XAIImageProvider implements IImageProvider { // rejects). We reuse the SDK client's auth + baseURL via its low-level // post(). Input images are passed as `{ type: 'image_url', url }` objects; // a single object for one image, an array for multiple. - async #edit( - modelId: string, - prompt: string, - inputImages: string[], - mimeHint: string | undefined, - resolution: string, - aspectRatio: string | undefined, - ): Promise { + async #edit(params: { + modelId: string; + prompt: string; + inputImages: string[]; + mimeHint: string | undefined; + resolution: string; + aspectRatio: string | undefined; + user: string | undefined; + }): Promise { + const { + modelId, + prompt, + inputImages, + mimeHint, + resolution, + aspectRatio, + user, + } = params; const refs = inputImages.map((img) => this.#toImageRef(img, mimeHint)); const body: Record = { model: modelId, @@ -187,6 +198,7 @@ export class XAIImageProvider implements IImageProvider { resolution, }; if (aspectRatio) body.aspect_ratio = aspectRatio; + if (user) body.user = user; return (await this.#client.post('/images/edits', { body, })) as XaiImageResponse; diff --git a/src/backend/drivers/integrationTestUtil.ts b/src/backend/drivers/integrationTestUtil.ts index 899e67415b..403dbe9255 100644 --- a/src/backend/drivers/integrationTestUtil.ts +++ b/src/backend/drivers/integrationTestUtil.ts @@ -30,8 +30,10 @@ * a test file. */ +import { expect } from 'vitest'; + import type { Actor } from '../core/actor.js'; -import { SYSTEM_ACTOR } from '../core/actor.js'; +import { SYSTEM_ACTOR, makeActor } from '../core/actor.js'; import { runWithContext } from '../core/context.js'; import type { MeteringService } from '../services/metering/MeteringService.js'; @@ -85,3 +87,58 @@ export const withTestActor = ( Promise.resolve( runWithContext({ actor, requestId: 'integration-test' }, fn), ); + +/** + * The four actor shapes provider tests exercise: a direct user session, the + * user's own app, an app-issued access token (attributed through + * `effectiveApp`), and the system actor. Shared so identifiers are tested + * identically across providers instead of copied per suite. + */ +export const makeActorMatrix = (): Actor[] => { + const user = { id: 42, uuid: 'u42', username: 'alice' }; + const app = { uid: 'app-abc' }; + return [ + makeActor({ user }), + makeActor({ user, app }), + makeActor({ + user, + accessToken: { uid: 'tok-1', issuer: makeActor({ user, app }) }, + }), + SYSTEM_ACTOR, + ]; +}; + +/** + * Asserts the four mock calls recorded from driving `makeActorMatrix()` through + * a provider carry the expected identifier: full uuid, uuid+app, uuid+app via + * access token, and undefined for the system actor. Pass `extraFields` for + * providers that also send the same identifier under other field names (e.g. + * `safety_identifier`, `user_id`, `prompt_cache_key`). + */ +export const assertActorMatrixIdentifiers = ( + calls: unknown[][], + extraFields?: string | string[], +): void => { + type Call = Record; + const [userOnly] = calls[0]! as [Call]; + const [withApp] = calls[1]! as [Call]; + const [tokenWithApp] = calls[2]! as [Call]; + const [system] = calls[3]! as [Call]; + + expect(userOnly.user).toBe('puter-u42'); + expect(withApp.user).toBe('puter-u42-app-abc'); + expect(tokenWithApp.user).toBe('puter-u42-app-abc'); + expect(system.user).toBeUndefined(); + + const fields = extraFields + ? Array.isArray(extraFields) + ? extraFields + : [extraFields] + : []; + for (const field of fields) { + expect(userOnly[field]).toBe('puter-u42'); + expect(withApp[field]).toBe('puter-u42-app-abc'); + expect(tokenWithApp[field]).toBe('puter-u42-app-abc'); + expect(system[field]).toBeUndefined(); + } +}; diff --git a/src/backend/drivers/util/aiUserIdentifier.test.ts b/src/backend/drivers/util/aiUserIdentifier.test.ts new file mode 100644 index 0000000000..dc1ea86564 --- /dev/null +++ b/src/backend/drivers/util/aiUserIdentifier.test.ts @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { describe, expect, it } from 'vitest'; +import type { Actor } from '../../core/actor.js'; +import { SYSTEM_ACTOR, makeActor } from '../../core/actor.js'; +import { aiUserIdentifier } from './aiUserIdentifier.js'; + +// Real production shapes: `user.uuid` is a UUID v4 (36 chars) and +// `app.uid` is `app-` (40 chars). +const REAL_UUID = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d'; +const REAL_APP_UID = `app-${REAL_UUID}`; + +describe('aiUserIdentifier', () => { + const user = { id: 42, uuid: 'u42', username: 'alice' }; + const appUid = 'app-abc'; + + it('derives a non-sequential id from the user uuid', () => { + expect(aiUserIdentifier(makeActor({ user }))).toBe('puter-u42'); + }); + + it('attaches the app uid when makeActor derives effectiveApp', () => { + const actor = makeActor({ user, app: { uid: appUid } }); + expect(actor.effectiveApp?.uid).toBe(appUid); + expect(aiUserIdentifier(actor)).toBe('puter-u42-app-abc'); + }); + + it('attaches the app through effectiveApp across an access token', () => { + const token = makeActor({ + user, + accessToken: { + uid: 'tok-1', + issuer: makeActor({ user, app: { uid: appUid } }), + }, + }); + expect(aiUserIdentifier(token)).toBe('puter-u42-app-abc'); + }); + + it('omits the app suffix for an access token issued by a plain user', () => { + const token = makeActor({ + user, + accessToken: { uid: 'tok-1', issuer: makeActor({ user }) }, + }); + expect(aiUserIdentifier(token)).toBe('puter-u42'); + }); + + it('reads only effectiveApp, ignoring a bare app on hand-built actors', () => { + const actor = { user, app: { uid: 'direct-app' } } as Actor; + expect(aiUserIdentifier(actor)).toBe('puter-u42'); + }); + + it('prefers effectiveApp over a bare app on hand-built actors', () => { + const actor = { + user, + app: { uid: 'direct-app' }, + effectiveApp: { uid: 'effective-app' }, + } as Actor; + expect(aiUserIdentifier(actor)).toBe('puter-u42-effective-app'); + }); + + it('returns undefined for the system actor', () => { + expect(aiUserIdentifier(SYSTEM_ACTOR)).toBeUndefined(); + expect(aiUserIdentifier(makeActor({ user, system: true }))).toBeUndefined(); + }); + + it('returns undefined without an actor or a user uuid', () => { + expect(aiUserIdentifier()).toBeUndefined(); + expect(aiUserIdentifier(null)).toBeUndefined(); + expect(aiUserIdentifier(makeActor({ user: { id: 42 } }))).toBeUndefined(); + }); + + it('keeps the full user uuid and truncates only the app token to fit maxLength', () => { + const actor = makeActor({ + user: { ...user, uuid: REAL_UUID }, + app: { uid: REAL_APP_UID }, + }); + const identifier = aiUserIdentifier(actor, 64)!; + // puter- (6) + uuid (36) + '-' (1) + app token cut to 21 chars = 64. + expect(identifier).toBe(`puter-${REAL_UUID}-${REAL_APP_UID.slice(0, 21)}`); + expect(identifier.length).toBe(64); + expect(identifier.startsWith(`puter-${REAL_UUID}`)).toBe(true); + }); + + it('omits the app token entirely when there is no room for it', () => { + const actor = makeActor({ + user: { ...user, uuid: REAL_UUID }, + app: { uid: appUid }, + }); + const base = `puter-${REAL_UUID}`; + // No budget for '-' inside the cap: the user-only form wins, + // and never a dangling separator. + expect(aiUserIdentifier(actor, base.length)).toBe(base); + expect(aiUserIdentifier(actor, base.length + 1)).toBe(base); + expect(aiUserIdentifier(actor, base.length + 1)).not.toMatch(/-$/); + }); + + it('never truncates the user uuid, even under a maxLength below the base length', () => { + const actor = makeActor({ user: { ...user, uuid: REAL_UUID } }); + const base = `puter-${REAL_UUID}`; + + expect(aiUserIdentifier(actor, 10)).toBe(base); + + const withApp = makeActor({ + user: { ...user, uuid: REAL_UUID }, + app: { uid: REAL_APP_UID }, + }); + + expect(aiUserIdentifier(withApp, 30)).toBe(base); + }); + + it('truncates a long app token to a larger maxLength deterministically', () => { + const actor = makeActor({ user, app: { uid: 'long-'.repeat(40) } }); + const at64 = aiUserIdentifier(actor, 64)!; + const at128 = aiUserIdentifier(actor, 128)!; + expect(at64).toHaveLength(64); + expect(at128).toHaveLength(128); + expect(at64.startsWith('puter-u42-')).toBe(true); + expect(at128.startsWith('puter-u42-')).toBe(true); + expect(aiUserIdentifier(actor)).toBe(at64); + }); +}); \ No newline at end of file diff --git a/src/backend/drivers/util/aiUserIdentifier.ts b/src/backend/drivers/util/aiUserIdentifier.ts new file mode 100644 index 0000000000..748a80965d --- /dev/null +++ b/src/backend/drivers/util/aiUserIdentifier.ts @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import type { Actor } from '../../core/actor.js'; +import { isSystemActor } from '../../core/actor.js'; + +// OpenAI's `safety_identifier` (Chat Completions and Responses; Azure mirrors +// the same contract) is documented at 64 characters. Z.AI's `user_id` is +// documented at 6-128, so ZAIProvider passes a larger `maxLength`. Meta's and +// xAI's APIs don't document a limit for this field; 64 is reused there as a +// safe default, not a verified vendor cap. The same value is also sent as +// the deprecated `user` field and, where forwarded, `prompt_cache_key` — +// neither documents a length limit of its own. +export const AI_USER_IDENTIFIER_MAX_LENGTH = 64; + +// Below this remaining budget, a truncated app uid loses enough of its +// distinguishing suffix that two different apps could collide; below the +// threshold we drop the app suffix entirely rather than risk that. +const MIN_APP_UID_BUDGET = 8; + +/** + * Builds Puter's stable, non-sequential AI actor identifier. + * + * The user's UUID is always preserved in full. When an app is present, + * maxLength limits the app-bearing form; the app token may be truncated or + * omitted when there is insufficient room. The user-only form is always + * returned in full. + * + * Reads only `effectiveApp` (never the bare `app`) so an actor literal built + * without `makeActor` — whose `effectiveApp` was never derived — degrades to + * the user-only identifier instead of silently attributing to the wrong app. + */ +export const aiUserIdentifier = ( + actor?: Actor | null, + maxLength: number = AI_USER_IDENTIFIER_MAX_LENGTH, +): string | undefined => { + if (!actor || isSystemActor(actor)) return undefined; + const userUuid = actor.user?.uuid; + if (!userUuid) return undefined; + const base = `puter-${userUuid}`; + const appUid = actor.effectiveApp?.uid; + if (!appUid || maxLength <= base.length + 1) return base; + const appBudget = maxLength - base.length - 1; + if (appBudget < MIN_APP_UID_BUDGET) return base; + return `${base}-${appUid.slice(0, appBudget)}`; +};