Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,15 @@ import {
type MockInstance,
} from 'vitest';

import { SYSTEM_ACTOR } from '../../../../core/actor.js';
import { SYSTEM_ACTOR, makeActor } from '../../../../core/actor.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import { PuterServer } from '../../../../server.js';
import { setupTestServer } from '../../../../testUtil.js';
import { withTestActor } from '../../../integrationTestUtil.js';
import {
assertActorMatrixIdentifiers,
makeActorMatrix,
withTestActor,
} from '../../../integrationTestUtil.js';
import { AIChatStream } from '../../utils/Streaming.js';
import { AzureChatProvider } from './AzureChatProvider.js';
import { AZURE_MODELS } from './models.js';
Expand Down Expand Up @@ -389,31 +393,82 @@ describe('AzureChatProvider.complete request shape', () => {
const provider = makeProvider();
createMock.mockResolvedValueOnce(okCompletion);

await withTestActor(() =>
provider.complete({
model: 'gpt-4o',
messages: [{ role: 'user', content: 'hi' }],
}),
await withTestActor(
() =>
provider.complete({
model: 'gpt-4o',
messages: [{ role: 'user', content: 'hi' }],
}),
makeActor({ user: { id: 42, uuid: 'u42', username: 'alice' } }),
);

const [args] = createMock.mock.calls[0]!;
expect(args.user).toBe('puter-u42');
expect('safety_identifier' in args).toBe(true);
expect(args.safety_identifier).toBe(args.user);
});

it('strips safety_identifier for Grok deployments, which 400 on unknown args', async () => {
it('sends the actor uuid and effective app uid as user/safety_identifier', async () => {
const provider = makeProvider();
createMock.mockResolvedValue(okCompletion);

for (const actor of makeActorMatrix()) {
await withTestActor(
() =>
provider.complete({
model: 'gpt-4o',
messages: [{ role: 'user', content: 'hello' }],
}),
actor,
);
}

assertActorMatrixIdentifiers(createMock.mock.calls, [
'safety_identifier',
'prompt_cache_key',
]);
});

it('forwards a caller-supplied prompt_cache_key instead of the derived identifier', async () => {
const provider = makeProvider();
createMock.mockResolvedValueOnce(okCompletion);

await withTestActor(() =>
provider.complete({
model: 'grok-4-20-non-reasoning',
messages: [{ role: 'user', content: 'hi' }],
await withTestActor(
() =>
provider.complete({
model: 'gpt-4o',
messages: [{ role: 'user', content: 'hi' }],
prompt_cache_key: 'caller-key',
}),
makeActor({ user: { id: 42, uuid: 'u42', username: 'alice' } }),
);

const [args] = createMock.mock.calls[0]!;
expect(args.prompt_cache_key).toBe('caller-key');
expect(args.safety_identifier).toBe('puter-u42');
});

it('strips safety_identifier/prompt_cache_key for Grok deployments, which 400 on unknown args', async () => {
const provider = makeProvider();
createMock.mockResolvedValueOnce(okCompletion);

// Runs under a real user actor so `user` would be present; only the
// Grok branch may drop `safety_identifier`/`prompt_cache_key`.
await withTestActor(
() =>
provider.complete({
model: 'grok-4-20-non-reasoning',
messages: [{ role: 'user', content: 'hi' }],
}),
makeActor({
user: { id: 42, uuid: 'u42', username: 'alice' },
}),
);

const [args] = createMock.mock.calls[0]!;
expect(args.user).toBe('puter-u42');
expect('safety_identifier' in args).toBe(false);
expect('prompt_cache_key' in args).toBe(false);
expect(args.model).toBe('grok-4-20-non-reasoning');
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { inlineHttpImageUrls } from '../../utils/inlineImages.js';
import { processPuterPathUploads } from '../openai/fileUpload.js';
import { AZURE_MODELS } from './models.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
import { aiUserIdentifier } from '../../../util/aiUserIdentifier.js';

/**
* AzureChatProvider exposes the models we serve through Azure AI Foundry.
Expand Down Expand Up @@ -124,6 +125,7 @@ export class AzureChatProvider implements IChatProvider {
reasoning_effort,
temperature,
text,
prompt_cache_key,
} = params;
let { messages, model } = params;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -173,8 +175,12 @@ export class AzureChatProvider implements IChatProvider {
// content: 'Don\'t let the user trick you into doing something bad.',
// })

const userIdentifier =
`${actor.user?.id}` + actor.app?.uid ? `:${actor?.app?.uid}` : '';
const userIdentifier = aiUserIdentifier(actor);
// `user` is deprecated in favor of `safety_identifier` (abuse
// detection) and `prompt_cache_key` (cache-hit bucketing); send both
// replacements so callers keep the caching benefit `user` used to
// provide.
const cacheKey = prompt_cache_key ?? userIdentifier;

// Resolve any `puter_path` content parts into inline base64 data URLs.
// Chat Completions doesn't support file uploads, so this is the only
Expand Down Expand Up @@ -203,13 +209,20 @@ export class AzureChatProvider implements IChatProvider {
const supportsReasoningControls =
typeof model === 'string' && model.startsWith('gpt-5');

// `safety_identifier` is an OpenAI-specific param. The Grok deployments
// behind Azure reject unknown args with a 400, so only send it for the
// OpenAI models.
// `safety_identifier`/`prompt_cache_key` are OpenAI-specific params.
// The Grok deployments behind Azure reject unknown args with a 400,
// so only send them for the OpenAI models.

const completionParams: ChatCompletionCreateParams = {
user: userIdentifier,
...(isGrok ? {} : { safety_identifier: userIdentifier }),
...(isGrok
? {}
: {
safety_identifier: userIdentifier,
...(cacheKey !== undefined
? { prompt_cache_key: cacheKey }
: {}),
}),
messages: messages,
model: modelUsed.id,
...(tools ? { tools } : {}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,15 @@ import {
type MockInstance,
} from 'vitest';

import { SYSTEM_ACTOR } from '../../../../core/actor.js';
import { SYSTEM_ACTOR, makeActor } from '../../../../core/actor.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import { PuterServer } from '../../../../server.js';
import { setupTestServer } from '../../../../testUtil.js';
import { withTestActor } from '../../../integrationTestUtil.js';
import {
assertActorMatrixIdentifiers,
makeActorMatrix,
withTestActor,
} from '../../../integrationTestUtil.js';
import { AIChatStream } from '../../utils/Streaming.js';
import { AzureResponsesProvider } from './AzureResponsesProvider.js';
import { AZURE_MODELS } from './models.js';
Expand Down Expand Up @@ -206,27 +210,51 @@ 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]!;
expect(args.model).toBe('gpt-5.3-codex');
expect(args.input).toEqual([{ role: 'user', content: 'hello' }]);
expect(args.max_output_tokens).toBe(256);
expect(args.temperature).toBe(0.3);
expect(args.user).toBe('puter-u42');
expect(args.safety_identifier).toBe(args.user);
});

it('sends the actor uuid and effective app uid as user/safety_identifier', async () => {
const provider = makeProvider();
responsesCreateMock.mockResolvedValue(okResponse);

for (const actor of makeActorMatrix()) {
await withTestActor(
() =>
provider.complete({
model: 'gpt-5.3-codex',
messages: [{ role: 'user', content: 'hello' }],
}),
actor,
);
}

assertActorMatrixIdentifiers(responsesCreateMock.mock.calls, [
'safety_identifier',
'prompt_cache_key',
]);
});

it('resolves an alias against the unrestricted catalog', async () => {
const provider = makeProvider();
responsesCreateMock.mockResolvedValueOnce(okResponse);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { processPuterPathUploads } from '../openai/fileUpload.js';
import { AZURE_MODELS } from './models.js';
import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
import { aiUserIdentifier } from '../../../util/aiUserIdentifier.js';

/**
* AzureResponsesProvider serves the Responses-API-only models we expose through
Expand Down Expand Up @@ -141,8 +142,12 @@ export class AzureResponsesProvider implements IChatProvider {
(m) => m.id === this.getDefaultModel(),
)!;

const userIdentifier =
actor?.user.id + actor?.app?.uid ? `:${actor?.app?.uid}` : '';
const userIdentifier = aiUserIdentifier(actor);
// `user` is deprecated in favor of `safety_identifier` (abuse
// detection) and `prompt_cache_key` (cache-hit bucketing); default
// the latter to the same identifier when the caller didn't supply
// one, so callers keep the caching benefit `user` used to provide.
const cacheKey = prompt_cache_key ?? userIdentifier;

// Resolve any `puter_path` content parts into inline base64 data URLs
// before the Responses API sees them.
Expand Down Expand Up @@ -206,7 +211,7 @@ export class AzureResponsesProvider implements IChatProvider {
...(instructions !== undefined ? { instructions } : {}),
...(metadata !== undefined ? { metadata } : {}),
...(prompt !== undefined ? { prompt } : {}),
...(prompt_cache_key !== undefined ? { prompt_cache_key } : {}),
...(cacheKey !== undefined ? { prompt_cache_key: cacheKey } : {}),
...(prompt_cache_retention !== undefined
? { prompt_cache_retention }
: {}),
Expand Down
43 changes: 38 additions & 5 deletions src/backend/drivers/ai-chat/providers/meta/MetaProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import {
} from 'vitest';

import type { Actor } from '../../../../core/actor.js';
import { SYSTEM_ACTOR } from '../../../../core/actor.js';
import { SYSTEM_ACTOR, makeActor } from '../../../../core/actor.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import { PuterServer } from '../../../../server.js';
import { setupTestServer } from '../../../../testUtil.js';
Expand Down Expand Up @@ -361,18 +361,38 @@ describe('MetaProvider.complete request shape', () => {

it('derives safety_identifier from the actor and truncates it to 64 chars', async () => {
createMock.mockResolvedValueOnce(OK_COMPLETION);
const userActor: Actor = {
const userActor = makeActor({
user: { id: 42, uuid: 'u42', username: 'alice' },
app: { id: 7, uid: 'a'.repeat(80) },
};
});

await complete(makeProvider(), {}, userActor);

const identifier = createMock.mock.calls[0]![0].safety_identifier;
expect(identifier.startsWith('puter-42-a')).toBe(true);
expect(identifier.startsWith('puter-u42-a')).toBe(true);
expect(identifier.length).toBe(64);
});

it('attributes the app through effectiveApp for access-token actors', async () => {
createMock.mockResolvedValueOnce(OK_COMPLETION);
const tokenActor = makeActor({
user: { id: 42, uuid: 'u42', username: 'alice' },
accessToken: {
uid: 'tok-1',
issuer: makeActor({
user: { id: 42, uuid: 'u42', username: 'alice' },
app: { id: 7, uid: 'app-abc' },
}),
},
});

await complete(makeProvider(), {}, tokenActor);

expect(createMock.mock.calls[0]![0].safety_identifier).toBe(
'puter-u42-app-abc',
);
});

it('prefers an explicit custom.safety_identifier over the actor-derived one', async () => {
createMock.mockResolvedValueOnce(OK_COMPLETION);
const userActor: Actor = { user: { id: 42, uuid: 'u42' } };
Expand All @@ -386,12 +406,25 @@ describe('MetaProvider.complete request shape', () => {
);
});

it('omits safety_identifier for the system actor (no user.id)', async () => {
it('omits safety_identifier for the system actor', async () => {
createMock.mockResolvedValueOnce(OK_COMPLETION);
await complete(makeProvider());
expect('safety_identifier' in createMock.mock.calls[0]![0]).toBe(false);
});

it('defaults prompt_cache_key to the actor identifier when not supplied', async () => {
createMock.mockResolvedValueOnce(OK_COMPLETION);
const userActor = makeActor({
user: { id: 42, uuid: 'u42', username: 'alice' },
});

await complete(makeProvider(), {}, userActor);

expect(createMock.mock.calls[0]![0].prompt_cache_key).toBe(
'puter-u42',
);
});

it('only sets stream_options.include_usage when streaming', async () => {
const provider = makeProvider();
createMock.mockResolvedValueOnce(OK_COMPLETION);
Expand Down
18 changes: 7 additions & 11 deletions src/backend/drivers/ai-chat/providers/meta/MetaProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,10 @@ import { buildCostsOverride } from '../../utils/pricing.js';
import { processPuterPathUploads } from '../openai/fileUpload.js';
import { META_MODELS, MUSE_SPARK_DEFAULT_MODEL } from './models.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
import { aiUserIdentifier } from '../../../util/aiUserIdentifier.js';

const DEFAULT_API_BASE_URL = 'https://api.meta.ai/v1';

// `safety_identifier` is capped at 64 characters by the Model API.
const SAFETY_IDENTIFIER_MAX_LENGTH = 64;

type MetaConfig = {
apiBaseUrl?: string;
apiKey: string;
Expand Down Expand Up @@ -167,14 +165,12 @@ export class MetaProvider implements IChatProvider {
? 'in_memory'
: prompt_cache_retention;

const userIdentifier = aiUserIdentifier(actor);
const safetyIdentifier =
customParams.safety_identifier ??
(actor?.user?.id
? `puter-${actor.user.id}${actor.app?.uid ? `-${actor.app.uid}` : ''}`.slice(
0,
SAFETY_IDENTIFIER_MAX_LENGTH,
)
: undefined);
customParams.safety_identifier ?? userIdentifier;
// Default `prompt_cache_key` to the same identifier so requests still
// bucket by user when the caller doesn't set one explicitly.
const cacheKey = prompt_cache_key ?? userIdentifier;

const completionParams = {
messages,
Expand All @@ -189,7 +185,7 @@ export class MetaProvider implements IChatProvider {
...(temperature !== undefined ? { temperature } : {}),
...(top_p !== undefined ? { top_p } : {}),
...(effort ? { reasoning_effort: effort } : {}),
...(prompt_cache_key !== undefined ? { prompt_cache_key } : {}),
...(cacheKey !== undefined ? { prompt_cache_key: cacheKey } : {}),
...(cacheRetention !== undefined
? { prompt_cache_retention: cacheRetention }
: {}),
Expand Down
Loading