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 66cc317f4..e1ad9272c 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,16 @@ 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 { + expectedIdentifierFields, + makeActorMatrix, + sentIdentifierFields, + withTestActor, +} from '../../../integrationTestUtil.js'; import { AIChatStream } from '../../utils/Streaming.js'; import { AzureChatProvider } from './AzureChatProvider.js'; import { AZURE_MODELS } from './models.js'; @@ -389,31 +394,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, + ); + } + + const fields = ['user', 'safety_identifier', 'prompt_cache_key']; + expect(sentIdentifierFields(createMock.mock.calls, fields)).toEqual( + expectedIdentifierFields(fields), + ); + }); + + 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 d701ecdbc..bbc92c4af 100644 --- a/src/backend/drivers/ai-chat/providers/azure/AzureChatProvider.ts +++ b/src/backend/drivers/ai-chat/providers/azure/AzureChatProvider.ts @@ -125,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 @@ -175,6 +176,8 @@ export class AzureChatProvider implements IChatProvider { // }) const userIdentifier = upstreamUserIdentifier(actor); + // Cache key defaults to the actor identifier; see upstreamUserIdentifier. + 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 +206,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 ab4686f6f..8942c7e47 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,16 @@ 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 { + expectedIdentifierFields, + makeActorMatrix, + sentIdentifierFields, + withTestActor, +} from '../../../integrationTestUtil.js'; import { AIChatStream } from '../../utils/Streaming.js'; import { AzureResponsesProvider } from './AzureResponsesProvider.js'; import { AZURE_MODELS } from './models.js'; @@ -206,17 +211,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 +231,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, + ); + } + + const fields = ['user', 'safety_identifier', 'prompt_cache_key']; + expect( + sentIdentifierFields(responsesCreateMock.mock.calls, fields), + ).toEqual(expectedIdentifierFields(fields)); + }); + 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 63c83b628..2d24a4c92 100644 --- a/src/backend/drivers/ai-chat/providers/azure/AzureResponsesProvider.ts +++ b/src/backend/drivers/ai-chat/providers/azure/AzureResponsesProvider.ts @@ -143,6 +143,8 @@ export class AzureResponsesProvider implements IChatProvider { )!; const userIdentifier = upstreamUserIdentifier(actor); + // Cache key defaults to the actor identifier; see upstreamUserIdentifier. + 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 +208,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 b4b5560ae..cdc24dd70 100644 --- a/src/backend/drivers/ai-chat/providers/meta/MetaProvider.test.ts +++ b/src/backend/drivers/ai-chat/providers/meta/MetaProvider.test.ts @@ -361,7 +361,7 @@ 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 = makeActor({ + const userActor = makeActor({ user: { id: 42, uuid: 'u42', username: 'alice' }, app: { id: 7, uid: 'a'.repeat(80) }, }); @@ -369,29 +369,60 @@ describe('MetaProvider.complete request shape', () => { 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('prefers an explicit custom.safety_identifier over the actor-derived one', async () => { + it('attributes the app through effectiveApp for access-token actors', async () => { createMock.mockResolvedValueOnce(OK_COMPLETION); - const userActor: Actor = { user: { id: 42, uuid: 'u42' } }; + 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('ignores a caller-supplied custom.safety_identifier', async () => { + createMock.mockResolvedValueOnce(OK_COMPLETION); + const userActor = makeActor({ user: { id: 42, uuid: 'u42' } }); await complete( makeProvider(), { custom: { safety_identifier: 'caller-supplied' } }, userActor, ); expect(createMock.mock.calls[0]![0].safety_identifier).toBe( - 'caller-supplied', + 'puter-u42', ); }); - 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 ac2524364..c5d0e36ae 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 { upstreamUserIdentifier } from '../../../util/upstreamIdentifier.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; @@ -50,7 +48,6 @@ type MetaCustomParams = { frequency_penalty?: number; presence_penalty?: number; response_format?: unknown; - safety_identifier?: string; seed?: number; }; @@ -167,14 +164,10 @@ export class MetaProvider implements IChatProvider { ? 'in_memory' : prompt_cache_retention; - const safetyIdentifier = - customParams.safety_identifier ?? - (actor?.user?.id - ? `puter-${actor.user.id}${actor.effectiveApp?.uid ? `-${actor.effectiveApp?.uid}` : ''}`.slice( - 0, - SAFETY_IDENTIFIER_MAX_LENGTH, - ) - : undefined); + // The identifier is Puter's abuse attribution, so `custom` can't + // override it. Cache key defaults to it; see upstreamUserIdentifier. + const userIdentifier = upstreamUserIdentifier(actor); + const cacheKey = prompt_cache_key ?? userIdentifier; const completionParams = { messages, @@ -189,13 +182,11 @@ 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 } : {}), - ...(safetyIdentifier - ? { safety_identifier: safetyIdentifier } - : {}), + ...(userIdentifier ? { safety_identifier: userIdentifier } : {}), ...(customParams.response_format ? { response_format: customParams.response_format } : {}), 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 d84368db4..ec5ed4238 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,16 @@ 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 { + expectedIdentifierFields, + makeActorMatrix, + sentIdentifierFields, + withTestActor, +} from '../../../integrationTestUtil.js'; import { AIChatStream } from '../../utils/Streaming.js'; import { OPEN_AI_MODELS } from './models.js'; import { OpenAiChatProvider } from './OpenAiChatCompletionsProvider.js'; @@ -270,6 +275,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, + ); + } + + const fields = ['user', 'safety_identifier', 'prompt_cache_key']; + expect(sentIdentifierFields(createMock.mock.calls, fields)).toEqual( + expectedIdentifierFields(fields), + ); + }); + + 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 599b53b32..4a9c6ff3b 100644 --- a/src/backend/drivers/ai-chat/providers/openai/OpenAiChatCompletionsProvider.ts +++ b/src/backend/drivers/ai-chat/providers/openai/OpenAiChatCompletionsProvider.ts @@ -109,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 @@ -159,6 +160,8 @@ export class OpenAiChatProvider implements IChatProvider { // }) const userIdentifier = upstreamUserIdentifier(actor); + // Cache key defaults to the actor identifier; see upstreamUserIdentifier. + 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 +186,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 ace3c29e1..2c5355ea0 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,12 @@ 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 { + expectedIdentifierFields, + makeActorMatrix, + sentIdentifierFields, + withTestActor, +} from '../../../integrationTestUtil.js'; import { AIChatStream } from '../../utils/Streaming.js'; import { OPEN_AI_MODELS } from './models.js'; import { OpenAiResponsesChatProvider } from './OpenAiChatResponsesProvider.js'; @@ -261,6 +266,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, + ); + } + + const fields = ['user', 'safety_identifier', 'prompt_cache_key']; + expect( + sentIdentifierFields(responsesCreateMock.mock.calls, fields), + ).toEqual(expectedIdentifierFields(fields)); + }); + 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 37ceab631..6026de0db 100644 --- a/src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.ts +++ b/src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.ts @@ -142,6 +142,8 @@ export class OpenAiResponsesChatProvider implements IChatProvider { // }) const userIdentifier = upstreamUserIdentifier(actor); + // Cache key defaults to the actor identifier; see upstreamUserIdentifier. + 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 +206,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 a0bdcd323..b259fa5e8 100644 --- a/src/backend/drivers/ai-chat/providers/zai/ZAIProvider.test.ts +++ b/src/backend/drivers/ai-chat/providers/zai/ZAIProvider.test.ts @@ -20,13 +20,12 @@ /** * Offline unit tests for ZAIProvider. * - * Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock - * redis) and constructs ZAIProvider directly against the live wired - * `MeteringService` so the recording side is exercised end-to-end. - * The OpenAI SDK is mocked at the module boundary — Z.AI is OpenAI- - * compatible so the provider talks to it through the same client — - * so the provider never reaches the network. The companion - * integration test (ZAIProvider.integration.test.ts) exercises the + * Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock redis) and + * constructs ZAIProvider directly against the live wired `MeteringService` so + * the recording side is exercised end-to-end. The OpenAI SDK is mocked at the + * module boundary — Z.AI is OpenAI- compatible so the provider talks to it + * through the same client — so the provider never reaches the network. The + * companion integration test (ZAIProvider.integration.test.ts) exercises the * real Z.AI endpoint. */ @@ -43,7 +42,6 @@ import { type MockInstance, } from 'vitest'; -import type { 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'; @@ -326,7 +324,7 @@ describe('ZAIProvider.complete request shape', () => { const { provider } = makeProvider(); createMock.mockResolvedValueOnce(baseCompletion); - const userActor: Actor = makeActor({ + const userActor = makeActor({ user: { id: 42, uuid: 'u42', username: 'alice' }, app: { id: 7, uid: 'app-uid' }, }); @@ -341,16 +339,42 @@ 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('prefers an explicit custom.user_id over the actor-derived one', async () => { + it('attributes the app through effectiveApp for access-token actors', async () => { const { provider } = makeProvider(); createMock.mockResolvedValueOnce(baseCompletion); - const userActor: Actor = { - user: { id: 42, uuid: 'u42' }, - }; + 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('ignores a caller-supplied custom.user_id', async () => { + const { provider } = makeProvider(); + createMock.mockResolvedValueOnce(baseCompletion); + + const userActor = makeActor({ user: { id: 42, uuid: 'u42' } }); await withTestActor( () => @@ -363,10 +387,10 @@ describe('ZAIProvider.complete request shape', () => { ); const [args] = createMock.mock.calls[0]!; - expect(args.user_id).toBe('caller-supplied'); + expect(args.user_id).toBe('puter-u42'); }); - 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 +402,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 913f92777..c1eac5006 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 { upstreamUserIdentifier } from '../../../util/upstreamIdentifier.js'; + +// Z.AI documents `user_id` as 6-128 characters. +const USER_ID_MAX_LENGTH = 128; type ZAIConfig = { apiBaseUrl?: string; @@ -41,7 +45,6 @@ type ZAICustomParams = { clear_thinking?: boolean; }; tool_stream?: boolean; - user_id?: string; }; const asRecord = (value: unknown): Record => @@ -103,14 +106,8 @@ export class ZAIProvider implements IChatProvider { }); const customParams = asRecord(custom) as ZAICustomParams; - const userId = - customParams.user_id ?? - (actor?.user?.id - ? `puter-${actor.user.id}${actor.effectiveApp?.uid ? `-${actor.effectiveApp?.uid}` : ''}`.slice( - 0, - 128, - ) - : undefined); + // Puter's abuse attribution; `custom` can't override it. + const userId = upstreamUserIdentifier(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 0a47e91bd..c1ae17eae 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,12 @@ import type { MeteringService } from '../../../../services/metering/MeteringServ import { SYSTEM_ACTOR, makeActor } from '../../../../core/actor.js'; import { PuterServer } from '../../../../server.js'; import { setupTestServer } from '../../../../testUtil.js'; -import { withTestActor } from '../../../integrationTestUtil.js'; +import { + expectedIdentifierFields, + makeActorMatrix, + sentIdentifierFields, + withTestActor, +} from '../../../integrationTestUtil.js'; import { OPEN_AI_IMAGE_GENERATION_MODELS } from './models.js'; import { OpenAiImageProvider } from './OpenAiImageProvider.js'; @@ -153,9 +158,9 @@ describe('OpenAiImageProvider model catalog', () => { it('no longer exposes any dall-e models', () => { const provider = makeProvider(); - expect( - provider.models().some((m) => m.id.startsWith('dall-e')), - ).toBe(false); + expect(provider.models().some((m) => m.id.startsWith('dall-e'))).toBe( + false, + ); }); it('exposes the static OPEN_AI_IMAGE_GENERATION_MODELS list verbatim', () => { @@ -184,8 +189,8 @@ describe('OpenAiImageProvider.generate test_mode', () => { describe('OpenAiImageProvider.generate user identifier', () => { it.each([ - { app: undefined, expected: '42' }, - { app: { uid: 'app-123' }, expected: '42:app-123' }, + { app: undefined, expected: 'puter-u42' }, + { app: { uid: 'app-123' }, expected: 'puter-u42-app-123' }, ])('sends $expected to OpenAI', async ({ app, expected }) => { generateMock.mockResolvedValueOnce({ data: [{ url: 'https://oai.example/img.png' }], @@ -193,8 +198,7 @@ describe('OpenAiImageProvider.generate user identifier', () => { await withTestActor( () => makeProvider().generate({ prompt: 'a tiny red dot' }), makeActor({ - ...SYSTEM_ACTOR, - user: { ...SYSTEM_ACTOR.user, id: 42 }, + user: { id: 42, uuid: 'u42', username: 'alice' }, app, }), ); @@ -281,6 +285,32 @@ 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, + ); + } + + const fields = ['user']; + expect(sentIdentifierFields(generateMock.mock.calls, fields)).toEqual( + expectedIdentifierFields(fields), + ); + }); +}); + // ── input_images / edit endpoint ─────────────────────────────────── describe('OpenAiImageProvider.generate input_images (edit endpoint)', () => { @@ -322,6 +352,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); @@ -345,7 +394,9 @@ describe('OpenAiImageProvider.generate input_images (edit endpoint)', () => { // gpt-image-2: text_input=500, image_input=800 (cents/1M tokens). // 40 text + 560 image tokens → (40*500 + 560*800)/1e6 cents. const expectedCents = (40 * 500 + 560 * 800) / 1_000_000; - expect(inputEntry?.costOverride).toBe(Math.ceil(expectedCents * 1_000_000)); + expect(inputEntry?.costOverride).toBe( + Math.ceil(expectedCents * 1_000_000), + ); }); it('folds singular input_image into the edit path with a single uploadable', async () => { @@ -461,9 +512,9 @@ describe('OpenAiImageProvider.generate gpt-image-* request shape', () => { // gpt-image-2 rates: text_input=500, image_output=3000 (cents/1M tokens). expect(batchIncrementUsagesSpy).toHaveBeenCalledTimes(1); const [, entries] = batchIncrementUsagesSpy.mock.calls[0]!; - const types = ( - entries as Array<{ usageType: string }> - ).map((e) => e.usageType); + const types = (entries as Array<{ usageType: string }>).map( + (e) => e.usageType, + ); expect(types).toEqual( expect.arrayContaining([ 'openai:gpt-image-2:low:1024x1024:input', 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 f641cba15..3c791554f 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,12 @@ import type { MeteringService } from '../../../../services/metering/MeteringServ import { SYSTEM_ACTOR, makeActor } from '../../../../core/actor.js'; import { PuterServer } from '../../../../server.js'; import { setupTestServer } from '../../../../testUtil.js'; -import { withTestActor } from '../../../integrationTestUtil.js'; +import { + expectedIdentifierFields, + makeActorMatrix, + sentIdentifierFields, + withTestActor, +} from '../../../integrationTestUtil.js'; import { XAI_IMAGE_GENERATION_MODELS } from './models.js'; import { XAIImageProvider } from './XAIImageProvider.js'; @@ -119,10 +124,7 @@ describe('XAIImageProvider construction', () => { it('throws when no apiKey is supplied', () => { expect( () => - new XAIImageProvider( - { apiKey: '' }, - server.services.metering, - ), + new XAIImageProvider({ apiKey: '' }, server.services.metering), ).toThrow(/API key/i); }); }); @@ -173,8 +175,8 @@ describe('XAIImageProvider.generate test_mode', () => { describe('XAIImageProvider.generate user identifier', () => { it.each([ - { app: undefined, expected: '42' }, - { app: { uid: 'app-123' }, expected: '42:app-123' }, + { app: undefined, expected: 'puter-u42' }, + { app: { uid: 'app-123' }, expected: 'puter-u42-app-123' }, ])('sends $expected to xAI', async ({ app, expected }) => { generateMock.mockResolvedValueOnce({ data: [{ url: 'https://x.ai/img/1' }], @@ -182,8 +184,7 @@ describe('XAIImageProvider.generate user identifier', () => { await withTestActor( () => makeProvider().generate({ prompt: 'a tiny red dot' }), makeActor({ - ...SYSTEM_ACTOR, - user: { ...SYSTEM_ACTOR.user, id: 42 }, + user: { id: 42, uuid: 'u42', username: 'alice' }, app, }), ); @@ -201,9 +202,7 @@ describe('XAIImageProvider.generate argument validation', () => { ).rejects.toMatchObject({ statusCode: 400 }); await expect( - withTestActor(() => - provider.generate({ prompt: ' ' }), - ), + withTestActor(() => provider.generate({ prompt: ' ' })), ).rejects.toMatchObject({ statusCode: 400 }); expect(generateMock).not.toHaveBeenCalled(); @@ -296,6 +295,29 @@ 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, + ); + } + + const fields = ['user']; + expect(sentIdentifierFields(generateMock.mock.calls, fields)).toEqual( + expectedIdentifierFields(fields), + ); + }); + it('uses the 2k output rate when quality is "2k"', async () => { const provider = makeProvider(); generateMock.mockResolvedValueOnce({ @@ -313,9 +335,9 @@ describe('XAIImageProvider.generate success path', () => { const sent = generateMock.mock.calls[0]![0]; expect(sent.resolution).toBe('2k'); const [, entries] = batchIncrementUsagesSpy.mock.calls[0]!; - expect( - (entries as Array<{ usageType: string }>)[0].usageType, - ).toBe('xai:grok-imagine-image-quality:output:2k'); + expect((entries as Array<{ usageType: string }>)[0].usageType).toBe( + 'xai:grok-imagine-image-quality:output:2k', + ); }); it('falls back to a base64 data URL when response carries b64_json', async () => { @@ -381,6 +403,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 with all five references', async () => { const provider = makeProvider(); postMock.mockResolvedValueOnce(editResponse); @@ -578,8 +620,8 @@ describe('xAI option validation', () => { }); it.each([ - { app: undefined, expected: '42' }, - { app: { uid: 'app-123' }, expected: '42:app-123' }, + { app: undefined, expected: 'puter-u42' }, + { app: { uid: 'app-123' }, expected: 'puter-u42-app-123' }, ])( 'includes the user identifier $expected on xAI edits', async ({ app, expected }) => { @@ -590,8 +632,7 @@ it.each([ () => makeProvider().generate({ prompt: 'hi', input_image: 'AQID' }), makeActor({ - ...SYSTEM_ACTOR, - user: { ...SYSTEM_ACTOR.user, id: 42 }, + user: { id: 42, uuid: 'u42', username: 'alice' }, app, }), ); diff --git a/src/backend/drivers/ai-image/providers/xai/XAIImageProvider.ts b/src/backend/drivers/ai-image/providers/xai/XAIImageProvider.ts index 488f0b2c4..910ec1c72 100644 --- a/src/backend/drivers/ai-image/providers/xai/XAIImageProvider.ts +++ b/src/backend/drivers/ai-image/providers/xai/XAIImageProvider.ts @@ -142,16 +142,16 @@ 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, imageQuality, userIdentifier, - ) + }) : ((await this.#client.images.generate({ model: selectedModel.id, prompt, @@ -198,29 +198,39 @@ 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, - quality: string | undefined, - user: string, - ): Promise { + async #edit(params: { + modelId: string; + prompt: string; + inputImages: string[]; + mimeHint: string | undefined; + resolution: string; + aspectRatio: string | undefined; + imageQuality: string | undefined; + userIdentifier: string | undefined; + }): Promise { + const { + modelId, + prompt, + inputImages, + mimeHint, + resolution, + aspectRatio, + imageQuality, + userIdentifier, + } = params; const refs = inputImages.map((img) => ({ type: 'image_url', url: toUrlOrDataUri(img, mimeHint), })); const body: Record = { model: modelId, - user, prompt, image: refs.length === 1 ? refs[0] : refs, resolution, }; if (aspectRatio) body.aspect_ratio = aspectRatio; - if (quality) body.quality = quality; + if (imageQuality) body.quality = imageQuality; + if (userIdentifier) body.user = userIdentifier; 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 899e67415..d14ea69a5 100644 --- a/src/backend/drivers/integrationTestUtil.ts +++ b/src/backend/drivers/integrationTestUtil.ts @@ -31,7 +31,7 @@ */ 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 +85,54 @@ 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, + ]; +}; + +/** Identifiers `makeActorMatrix()` should produce, in matrix order. */ +export const ACTOR_MATRIX_IDENTIFIERS: (string | undefined)[] = [ + 'puter-u42', + 'puter-u42-app-abc', + 'puter-u42-app-abc', + undefined, +]; + +/** + * Picks `fields` off the first argument of each recorded mock call, keyed by + * field, so a suite can compare what a provider sent against + * `expectedIdentifierFields(fields)` with one `toEqual`. + */ +export const sentIdentifierFields = ( + calls: unknown[][], + fields: string[], +): Record => + Object.fromEntries( + fields.map((field) => [ + field, + calls.map((call) => (call[0] as Record)[field]), + ]), + ); + +export const expectedIdentifierFields = ( + fields: string[], +): Record => + Object.fromEntries( + fields.map((field) => [field, ACTOR_MATRIX_IDENTIFIERS]), + ); diff --git a/src/backend/drivers/util/aiUserIdentifier.test.ts b/src/backend/drivers/util/aiUserIdentifier.test.ts new file mode 100644 index 000000000..39dc5ee11 --- /dev/null +++ b/src/backend/drivers/util/aiUserIdentifier.test.ts @@ -0,0 +1,143 @@ +/* + * 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); + }); +}); diff --git a/src/backend/drivers/util/aiUserIdentifier.ts b/src/backend/drivers/util/aiUserIdentifier.ts new file mode 100644 index 000000000..087c29f62 --- /dev/null +++ b/src/backend/drivers/util/aiUserIdentifier.ts @@ -0,0 +1,20 @@ +/* + * 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 . + */ + +export { upstreamUserIdentifier as aiUserIdentifier } from './upstreamIdentifier.js'; diff --git a/src/backend/drivers/util/upstreamIdentifier.test.ts b/src/backend/drivers/util/upstreamIdentifier.test.ts index 85b5203c2..0a10a308f 100644 --- a/src/backend/drivers/util/upstreamIdentifier.test.ts +++ b/src/backend/drivers/util/upstreamIdentifier.test.ts @@ -24,13 +24,13 @@ import { upstreamUserIdentifier } from './upstreamIdentifier.js'; describe('upstreamUserIdentifier', () => { const user = { id: 42, uuid: 'u-42', username: 'alice' }; - it('names a plain user by id alone', () => { - expect(upstreamUserIdentifier(makeActor({ user }))).toBe('42'); + it('names a plain user by stable uuid', () => { + expect(upstreamUserIdentifier(makeActor({ user }))).toBe('puter-u-42'); }); it('appends the app an app-under-user actor carries', () => { const actor = makeActor({ user, app: { uid: 'app-1', id: 7 } }); - expect(upstreamUserIdentifier(actor)).toBe('42:app-1'); + expect(upstreamUserIdentifier(actor)).toBe('puter-u-42-app-1'); }); it('attributes an app-issued access token to the issuing app', () => { @@ -39,12 +39,12 @@ describe('upstreamUserIdentifier', () => { user, accessToken: { uid: 'tok-1', issuer }, }); - expect(upstreamUserIdentifier(token)).toBe('42:app-1'); + expect(upstreamUserIdentifier(token)).toBe('puter-u-42-app-1'); }); - it('is empty when there is no user to name', () => { - expect(upstreamUserIdentifier(undefined)).toBe(''); - expect(upstreamUserIdentifier(null)).toBe(''); - expect(upstreamUserIdentifier(SYSTEM_ACTOR)).toBe(''); + it('is undefined when there is no user to name', () => { + expect(upstreamUserIdentifier(undefined)).toBeUndefined(); + expect(upstreamUserIdentifier(null)).toBeUndefined(); + expect(upstreamUserIdentifier(SYSTEM_ACTOR)).toBeUndefined(); }); }); diff --git a/src/backend/drivers/util/upstreamIdentifier.ts b/src/backend/drivers/util/upstreamIdentifier.ts index 10ba3a794..a6e1ace4f 100644 --- a/src/backend/drivers/util/upstreamIdentifier.ts +++ b/src/backend/drivers/util/upstreamIdentifier.ts @@ -18,21 +18,42 @@ */ import type { Actor } from '../../core/actor.js'; +import { isSystemActor } from '../../core/actor.js'; + +// OpenAI and Meta both document `safety_identifier` at 64 characters; Azure +// mirrors OpenAI's contract. xAI documents no cap, so 64 is a safe default +// there. Z.AI's `user_id` allows 6-128, so ZAIProvider passes a larger cap. +const DEFAULT_MAX_LENGTH = 64; + +// A truncated app uid shorter than this could collide with another app's, so +// the suffix is dropped instead of squeezed. +const MIN_APP_UID_BUDGET = 8; /** - * Per-caller identifier passed to an upstream provider so it can bucket abuse - * signals by account instead of by our whole tenancy. `[:]`, on - * the app the caller acts as — a token an app issued belongs to that app's - * bucket, not to the account's. + * Stable, non-sequential identifier for the acting user (and app) to send to AI + * vendors as `user` / `safety_identifier` / `prompt_cache_key`: + * `puter-[-]`. * - * Empty string when there is no user to name, which is what every provider - * treats as "unattributed". + * The user uuid is never truncated; only the app suffix is cut or dropped to + * fit `maxLength`. The app comes from `effectiveApp` so access-token requests + * are attributed to the issuing app. Returns undefined for the system actor. + * + * The same value doubles as the default `prompt_cache_key`: OpenAI recommends + * one key per user whose cache accounting should stay separate, and per-user + * volume stays under the per-key routing budget. A shared prefix therefore + * isn't cache-shared across users of one app; that's a deliberate trade. */ export const upstreamUserIdentifier = ( - actor: Actor | undefined | null, -): string => { - const userId = actor?.user?.id; - if (userId === undefined || userId === null) return ''; + actor?: Actor | null, + maxLength: number = DEFAULT_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; - return appUid ? `${userId}:${appUid}` : `${userId}`; + if (!appUid) return base; + const appBudget = maxLength - base.length - 1; + if (appBudget < MIN_APP_UID_BUDGET) return base; + return `${base}-${appUid.slice(0, appBudget)}`; };