fix(ai): send stable, non-sequential user identifiers to AI providers (#3856)

* 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-<user-uuid>[-<app-token>]`: 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.

* fix(ai): lock the vendor identifier down and keep vitest out of the test util

Meta and Z.AI no longer let `custom` override the abuse identifier; it is
Puter's attribution, not the caller's. The shared test util exposes pure
field pickers instead of importing vitest into a file the production
tsconfig compiles. The helper's length-cap comment now matches vendor docs
(Meta does cap `safety_identifier` at 64), the redundant budget branch and
the unused export are gone, and the per-user `prompt_cache_key` trade-off is
stated once in the helper instead of five times in providers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: 404oops <me@404oops.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Rasad Regmi
2026-09-21 16:21:47 -07:00
committed by GitHub
co-authored by Claude Fable 5.1 404oops
parent 027e9a71f3
commit 922d203e18
20 changed files with 701 additions and 147 deletions
@@ -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');
});
@@ -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 } : {}),
@@ -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);
@@ -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 }
: {}),
@@ -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);
@@ -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 }
: {}),
@@ -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);
@@ -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 } : {}),
@@ -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);
@@ -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 }
: {}),
@@ -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);
});
@@ -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<string, unknown> =>
@@ -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,
@@ -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',
@@ -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<string, unknown> }
).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,
}),
);
@@ -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<XaiImageResponse> {
async #edit(params: {
modelId: string;
prompt: string;
inputImages: string[];
mimeHint: string | undefined;
resolution: string;
aspectRatio: string | undefined;
imageQuality: string | undefined;
userIdentifier: string | undefined;
}): Promise<XaiImageResponse> {
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<string, unknown> = {
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;
+52 -1
View File
@@ -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 = <T>(
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<string, unknown[]> =>
Object.fromEntries(
fields.map((field) => [
field,
calls.map((call) => (call[0] as Record<string, unknown>)[field]),
]),
);
export const expectedIdentifierFields = (
fields: string[],
): Record<string, unknown[]> =>
Object.fromEntries(
fields.map((field) => [field, ACTOR_MATRIX_IDENTIFIERS]),
);
@@ -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 <https://www.gnu.org/licenses/>.
*/
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-<uuid v4>` (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 '-<token>' 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);
});
});
@@ -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 <https://www.gnu.org/licenses/>.
*/
export { upstreamUserIdentifier as aiUserIdentifier } from './upstreamIdentifier.js';
@@ -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();
});
});
+32 -11
View File
@@ -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. `<userId>[:<appUid>]`, 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-<user-uuid>[-<app-uid>]`.
*
* 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)}`;
};