mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-01 09:38:21 +00:00
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:
co-authored by
Claude Fable 5.1
404oops
parent
027e9a71f3
commit
922d203e18
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)}`;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user