mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-22 20:46:38 +08:00
feat(server): refactor for byok (#14911)
This commit is contained in:
-11
@@ -526,17 +526,6 @@ Generated by [AVA](https://avajs.dev).
|
||||
remoteAttachmentRequests: [],
|
||||
}
|
||||
|
||||
## PerplexityProvider should ignore attachments during text model matching
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
{
|
||||
text: 'summarize this',
|
||||
type: 'text',
|
||||
},
|
||||
]
|
||||
|
||||
## GeminiVertexProvider should prefetch bearer token for native config
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -771,7 +771,7 @@ function actionRunRecord(
|
||||
};
|
||||
}
|
||||
|
||||
function installActionSessionMock(
|
||||
async function installActionSessionMock(
|
||||
t: ExecutionContext<Tester>,
|
||||
{
|
||||
actionId,
|
||||
@@ -786,8 +786,12 @@ function installActionSessionMock(
|
||||
const { models, session } = t.context;
|
||||
const sandbox = Sinon.createSandbox();
|
||||
const sessionId = `copilot-provider-action-${actionId}-${randomUUID()}`;
|
||||
const userId = `copilot-provider-user-${randomUUID()}`;
|
||||
const workspaceId = `copilot-provider-action-${actionId}`;
|
||||
const user = await models.user.create({
|
||||
email: `copilot-provider-user-${randomUUID()}@affine.test`,
|
||||
});
|
||||
const userId = user.id;
|
||||
const workspace = await models.workspace.create(userId);
|
||||
const workspaceId = workspace.id;
|
||||
const docId = `copilot-provider-action-${actionId}-doc`;
|
||||
const savedTurns: Array<{ role: string }> = [];
|
||||
const userTurn = {
|
||||
@@ -904,7 +908,11 @@ for (const { actionId, content, verifier } of actionRecipeCases) {
|
||||
}
|
||||
|
||||
const { sandbox, sessionId, userId, savedTurns } =
|
||||
installActionSessionMock(t, { actionId, actionPrompt, content });
|
||||
await installActionSessionMock(t, {
|
||||
actionId,
|
||||
actionPrompt,
|
||||
content,
|
||||
});
|
||||
|
||||
let result = '';
|
||||
try {
|
||||
@@ -976,8 +984,10 @@ for (const testCase of TRANSCRIPT_AUDIO_CASES) {
|
||||
runIfCopilotConfigured,
|
||||
async t => {
|
||||
const { models, transcript } = t.context;
|
||||
const userId = `copilot-provider-transcript-user-${randomUUID()}`;
|
||||
const workspaceId = `copilot-provider-transcript-workspace-${randomUUID()}`;
|
||||
const user = await models.user.create({
|
||||
email: `copilot-provider-transcript-${randomUUID()}@affine.pro`,
|
||||
});
|
||||
const workspace = await models.workspace.create(user.id);
|
||||
const blobId = `copilot-provider-transcript-blob-${randomUUID()}`;
|
||||
const payload = TranscriptPayloadSchema.parse({
|
||||
sourceAudio: { blobId, mimeType: testCase.mimeType },
|
||||
@@ -990,8 +1000,8 @@ for (const testCase of TRANSCRIPT_AUDIO_CASES) {
|
||||
],
|
||||
});
|
||||
const task = await models.copilotTranscriptTask.create({
|
||||
userId,
|
||||
workspaceId,
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
blobId,
|
||||
strategy: 'gemini',
|
||||
recipeId: 'transcript.audio.gemini',
|
||||
|
||||
@@ -139,9 +139,6 @@ test.before(async t => {
|
||||
fal: {
|
||||
apiKey: process.env.COPILOT_FAL_API_KEY ?? '1',
|
||||
},
|
||||
perplexity: {
|
||||
apiKey: process.env.COPILOT_PERPLEXITY_API_KEY ?? '1',
|
||||
},
|
||||
anthropic: {
|
||||
apiKey: process.env.COPILOT_ANTHROPIC_API_KEY ?? '1',
|
||||
},
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import test from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import type { Models } from '../../models';
|
||||
import { type Models } from '../../models';
|
||||
import { CopilotAccessPolicy } from '../../plugins/copilot/access';
|
||||
import type { ByokFeatureKind } from '../../plugins/copilot/byok/types';
|
||||
import { HistoryAttachmentUrlProjector } from '../../plugins/copilot/compat/history-attachment-url-projector';
|
||||
import { CompatHistoryProjector } from '../../plugins/copilot/compat/history-projector';
|
||||
import { HistoryPromptPreloadProjector } from '../../plugins/copilot/compat/history-prompt-preload-projector';
|
||||
import { HistoryVisibilityPolicy } from '../../plugins/copilot/compat/history-visibility-policy';
|
||||
import { ConversationPolicy } from '../../plugins/copilot/conversation/policy';
|
||||
import type { Turn } from '../../plugins/copilot/core';
|
||||
import { CopilotEmbeddingClientService } from '../../plugins/copilot/embedding/client';
|
||||
import { CopilotProviderType } from '../../plugins/copilot/providers/types';
|
||||
import {
|
||||
@@ -29,9 +33,11 @@ import {
|
||||
AttachmentMaterializer,
|
||||
resolveAttachmentFetchUrl,
|
||||
} from '../../plugins/copilot/runtime/hosts/attachment-materializer';
|
||||
import { ConversationHost } from '../../plugins/copilot/runtime/hosts/conversation-host';
|
||||
import { ImageResultHost } from '../../plugins/copilot/runtime/hosts/image-result-host';
|
||||
import { ResponsePostprocessor } from '../../plugins/copilot/runtime/hosts/response-postprocessor';
|
||||
import { TurnPersistence } from '../../plugins/copilot/runtime/hosts/turn-persistence';
|
||||
import { ToolRuntime } from '../../plugins/copilot/runtime/tool-runtime';
|
||||
|
||||
function stubTurnPersistence(
|
||||
persistProjectedResult: Sinon.SinonStub = Sinon.stub().resolves(null)
|
||||
@@ -41,6 +47,367 @@ function stubTurnPersistence(
|
||||
} as unknown as TurnPersistence;
|
||||
}
|
||||
|
||||
function stubConversationSession(latestUserTurn?: unknown) {
|
||||
return {
|
||||
config: {
|
||||
sessionId: 'session-1',
|
||||
userId: 'user-1',
|
||||
workspaceId: 'workspace-1',
|
||||
},
|
||||
model: 'gpt-4o-mini',
|
||||
stashTurns: latestUserTurn ? [latestUserTurn] : [],
|
||||
latestUserTurn,
|
||||
revertLatestMessage: Sinon.stub(),
|
||||
};
|
||||
}
|
||||
|
||||
test('ConversationPolicy should treat zero quota limit as exhausted', async t => {
|
||||
const policy = new ConversationPolicy(
|
||||
{
|
||||
userFeature: { has: Sinon.stub().resolves(false) },
|
||||
copilotSession: { countUserMessages: Sinon.stub().resolves(0) },
|
||||
} as any,
|
||||
{
|
||||
getUserQuota: Sinon.stub().resolves({ copilotActionLimit: 0 }),
|
||||
} as any
|
||||
);
|
||||
|
||||
t.false(await policy.hasQuota('user-1'));
|
||||
await t.throwsAsync(policy.checkQuota('user-1'));
|
||||
});
|
||||
|
||||
type TurnRouteAccessCase = {
|
||||
name: string;
|
||||
profiles: Array<{ id: string }>;
|
||||
featureKind?: 'embedding' | 'rerank' | 'workspace_indexing';
|
||||
byokLeaseId?: string;
|
||||
quotaBackedRoutesAllowed?: boolean;
|
||||
expectedQuotaCalls: number;
|
||||
expectedError?: string;
|
||||
expectedQuotaBackedRoutesAllowed?: boolean;
|
||||
};
|
||||
|
||||
const turnRouteAccessCases: TurnRouteAccessCase[] = [
|
||||
{
|
||||
name: 'checks quota when BYOK does not cover the route',
|
||||
profiles: [],
|
||||
expectedQuotaCalls: 1,
|
||||
expectedError: 'quota exceeded',
|
||||
},
|
||||
{
|
||||
name: 'skips quota when BYOK covers the route',
|
||||
profiles: [{ id: 'profile-1' }],
|
||||
byokLeaseId: 'lease-1',
|
||||
expectedQuotaCalls: 0,
|
||||
expectedQuotaBackedRoutesAllowed: undefined,
|
||||
},
|
||||
{
|
||||
name: 'preserves explicit quota-backed route disable override',
|
||||
profiles: [],
|
||||
quotaBackedRoutesAllowed: false,
|
||||
expectedQuotaCalls: 0,
|
||||
expectedQuotaBackedRoutesAllowed: false,
|
||||
},
|
||||
{
|
||||
name: 'does not check user quota for unmetered service features',
|
||||
profiles: [],
|
||||
featureKind: 'rerank',
|
||||
expectedQuotaCalls: 0,
|
||||
expectedQuotaBackedRoutesAllowed: true,
|
||||
},
|
||||
];
|
||||
|
||||
for (const matrixCase of turnRouteAccessCases) {
|
||||
test(`CopilotAccessPolicy resolve turn route access: ${matrixCase.name}`, async t => {
|
||||
const checkQuota = Sinon.stub().rejects(new Error('quota exceeded'));
|
||||
const getProfiles = Sinon.stub().resolves(matrixCase.profiles);
|
||||
const access = new CopilotAccessPolicy(
|
||||
{ checkQuota } as any,
|
||||
{ getProfiles } as any
|
||||
);
|
||||
|
||||
const promise = access.resolveTurnRouteAccess({
|
||||
userId: 'user-1',
|
||||
workspaceId: 'workspace-1',
|
||||
byokLeaseId: matrixCase.byokLeaseId,
|
||||
featureKind: matrixCase.featureKind,
|
||||
quotaBackedRoutesAllowed: matrixCase.quotaBackedRoutesAllowed,
|
||||
});
|
||||
|
||||
if (matrixCase.expectedError) {
|
||||
await t.throwsAsync(promise, { message: matrixCase.expectedError });
|
||||
} else {
|
||||
const routeAccess = await promise;
|
||||
t.is(
|
||||
routeAccess.quotaBackedRoutesAllowed,
|
||||
matrixCase.expectedQuotaBackedRoutesAllowed
|
||||
);
|
||||
}
|
||||
t.is(checkQuota.callCount, matrixCase.expectedQuotaCalls);
|
||||
if (matrixCase.expectedQuotaCalls) {
|
||||
Sinon.assert.calledWithExactly(checkQuota, 'user-1');
|
||||
}
|
||||
if (matrixCase.byokLeaseId) {
|
||||
Sinon.assert.calledWithMatch(getProfiles, {
|
||||
byokLeaseId: matrixCase.byokLeaseId,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
type ByokCoverageCase = {
|
||||
featureKind?: ByokFeatureKind;
|
||||
expected: { local: boolean; server: boolean };
|
||||
};
|
||||
|
||||
const byokCoverageCases: ByokCoverageCase[] = [
|
||||
{ featureKind: 'chat', expected: { local: true, server: true } },
|
||||
{ featureKind: 'action', expected: { local: true, server: true } },
|
||||
{ featureKind: 'image', expected: { local: true, server: true } },
|
||||
{ featureKind: 'transcript', expected: { local: false, server: true } },
|
||||
{ featureKind: 'embedding', expected: { local: false, server: true } },
|
||||
{
|
||||
featureKind: 'workspace_indexing',
|
||||
expected: { local: false, server: true },
|
||||
},
|
||||
{ featureKind: 'rerank', expected: { local: false, server: true } },
|
||||
{ expected: { local: true, server: true } },
|
||||
];
|
||||
|
||||
for (const matrixCase of byokCoverageCases) {
|
||||
test(`CopilotAccessPolicy should resolve BYOK coverage for ${matrixCase.featureKind ?? 'default'}`, async t => {
|
||||
const getProfiles = Sinon.stub().resolves([]);
|
||||
const access = new CopilotAccessPolicy(
|
||||
{ hasQuota: Sinon.stub().resolves(true) } as any,
|
||||
{ getProfiles } as any
|
||||
);
|
||||
|
||||
await access.getByokProfiles({
|
||||
userId: 'user-1',
|
||||
workspaceId: 'workspace-1',
|
||||
featureKind: matrixCase.featureKind,
|
||||
});
|
||||
|
||||
t.like(getProfiles.firstCall.args[0], {
|
||||
userId: 'user-1',
|
||||
workspaceId: 'workspace-1',
|
||||
});
|
||||
t.is(getProfiles.firstCall.args[0].featureKind, matrixCase.featureKind);
|
||||
t.deepEqual(getProfiles.firstCall.args[1], matrixCase.expected);
|
||||
});
|
||||
}
|
||||
|
||||
test('CopilotAccessPolicy assertQuotaOrByok should honor quota-backed route disable', async t => {
|
||||
const checkQuota = Sinon.stub().resolves(undefined);
|
||||
const access = new CopilotAccessPolicy(
|
||||
{ checkQuota } as any,
|
||||
{ getProfiles: Sinon.stub().resolves([]) } as any
|
||||
);
|
||||
|
||||
await t.throwsAsync(
|
||||
access.assertQuotaOrByok({
|
||||
userId: 'user-1',
|
||||
workspaceId: 'workspace-1',
|
||||
featureKind: 'transcript',
|
||||
quotaBackedRoutesAllowed: false,
|
||||
})
|
||||
);
|
||||
Sinon.assert.notCalled(checkQuota);
|
||||
});
|
||||
|
||||
test('ConversationHost should delegate empty no-message stream access', async t => {
|
||||
const session = stubConversationSession();
|
||||
const resolveTurnRouteAccess = Sinon.stub().rejects(
|
||||
new Error('quota exceeded')
|
||||
);
|
||||
const host = new ConversationHost(
|
||||
{
|
||||
get: Sinon.stub().resolves(session),
|
||||
revertLatestMessage: Sinon.stub().resolves(undefined),
|
||||
} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{ resolveTurnRouteAccess } as any
|
||||
);
|
||||
|
||||
await t.throwsAsync(host.prepareTurn('user-1', 'session-1', {}), {
|
||||
message: 'quota exceeded',
|
||||
});
|
||||
Sinon.assert.calledOnceWithMatch(resolveTurnRouteAccess, {
|
||||
userId: 'user-1',
|
||||
workspaceId: 'workspace-1',
|
||||
});
|
||||
});
|
||||
|
||||
test('ConversationHost should return access decision for empty no-message stream', async t => {
|
||||
const session = stubConversationSession();
|
||||
const resolveTurnRouteAccess = Sinon.stub().resolves({
|
||||
byokProfiles: [{ id: 'profile-1' }],
|
||||
quotaBackedRoutesAllowed: undefined,
|
||||
});
|
||||
const host = new ConversationHost(
|
||||
{
|
||||
get: Sinon.stub().resolves(session),
|
||||
revertLatestMessage: Sinon.stub().resolves(undefined),
|
||||
} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{ resolveTurnRouteAccess } as any
|
||||
);
|
||||
|
||||
const prepared = await host.prepareTurn('user-1', 'session-1', {});
|
||||
|
||||
t.is(prepared.latestTurn, undefined);
|
||||
t.is(prepared.quotaBackedRoutesAllowed, undefined);
|
||||
Sinon.assert.calledOnce(resolveTurnRouteAccess);
|
||||
});
|
||||
|
||||
test('ConversationHost should replay accepted tokens without rechecking quota', async t => {
|
||||
const acceptedTurn: Turn = {
|
||||
id: 'turn-1',
|
||||
conversationId: 'session-1',
|
||||
role: 'user',
|
||||
content: 'hello',
|
||||
attachments: [],
|
||||
metadata: {},
|
||||
renderTrace: [],
|
||||
toolEvents: [],
|
||||
createdAt: new Date(),
|
||||
};
|
||||
const session = {
|
||||
...stubConversationSession(acceptedTurn),
|
||||
findTurn: Sinon.stub().withArgs('turn-1').returns(acceptedTurn),
|
||||
};
|
||||
const resolveTurnRouteAccess = Sinon.stub().rejects(
|
||||
new Error('quota exceeded')
|
||||
);
|
||||
const host = new ConversationHost(
|
||||
{
|
||||
get: Sinon.stub().resolves(session),
|
||||
revertLatestMessage: Sinon.stub().resolves(undefined),
|
||||
} as any,
|
||||
{
|
||||
getAccepted: Sinon.stub().resolves({
|
||||
sessionId: 'session-1',
|
||||
turnId: 'turn-1',
|
||||
}),
|
||||
} as any,
|
||||
{} as any,
|
||||
{ resolveTurnRouteAccess } as any
|
||||
);
|
||||
|
||||
const prepared = await host.prepareTurn('user-1', 'session-1', {
|
||||
messageId: 'message-1',
|
||||
});
|
||||
|
||||
t.is(prepared.latestTurn, acceptedTurn);
|
||||
t.true(prepared.quotaBackedRoutesAllowed);
|
||||
Sinon.assert.notCalled(resolveTurnRouteAccess);
|
||||
});
|
||||
|
||||
test('ConversationHost should replay durable tokens without rechecking quota', async t => {
|
||||
const durableTurn: Turn = {
|
||||
id: 'turn-1',
|
||||
conversationId: 'session-1',
|
||||
role: 'user',
|
||||
content: 'hello',
|
||||
attachments: [],
|
||||
metadata: {},
|
||||
renderTrace: [],
|
||||
toolEvents: [],
|
||||
createdAt: new Date(),
|
||||
};
|
||||
const session = {
|
||||
...stubConversationSession(durableTurn),
|
||||
findTurn: Sinon.stub().withArgs('turn-1').returns(durableTurn),
|
||||
pushPersistedTurn: Sinon.stub(),
|
||||
};
|
||||
const resolveTurnRouteAccess = Sinon.stub().rejects(
|
||||
new Error('quota exceeded')
|
||||
);
|
||||
const markAccepted = Sinon.stub().resolves(undefined);
|
||||
const host = new ConversationHost(
|
||||
{
|
||||
get: Sinon.stub().resolves(session),
|
||||
findTurnByCompatSubmissionId: Sinon.stub().resolves(durableTurn),
|
||||
revertLatestMessage: Sinon.stub().resolves(undefined),
|
||||
} as any,
|
||||
{
|
||||
getAccepted: Sinon.stub().resolves(undefined),
|
||||
markAccepted,
|
||||
} as any,
|
||||
{
|
||||
acquire: Sinon.stub().resolves({
|
||||
[Symbol.asyncDispose]: Sinon.stub().resolves(undefined),
|
||||
}),
|
||||
} as any,
|
||||
{ resolveTurnRouteAccess } as any
|
||||
);
|
||||
|
||||
const prepared = await host.prepareTurn('user-1', 'session-1', {
|
||||
messageId: 'message-1',
|
||||
});
|
||||
|
||||
t.is(prepared.latestTurn, durableTurn);
|
||||
t.true(prepared.quotaBackedRoutesAllowed);
|
||||
Sinon.assert.calledOnceWithMatch(markAccepted, 'message-1', {
|
||||
sessionId: 'session-1',
|
||||
turnId: 'turn-1',
|
||||
});
|
||||
Sinon.assert.notCalled(resolveTurnRouteAccess);
|
||||
});
|
||||
|
||||
test('ToolRuntime should pass route context into prompt-backed tools', async t => {
|
||||
const promptRuntime = {
|
||||
runText: Sinon.stub().resolves('<html><body>done</body></html>'),
|
||||
};
|
||||
const runtime = new ToolRuntime(
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
promptRuntime as any,
|
||||
{} as any
|
||||
);
|
||||
|
||||
const tools = await runtime.getTools(
|
||||
{
|
||||
tools: ['codeArtifact'],
|
||||
user: 'user-1',
|
||||
session: 'session-1',
|
||||
workspace: 'workspace-1',
|
||||
byokLeaseId: 'lease-1',
|
||||
featureKind: 'chat',
|
||||
quotaBackedRoutesAllowed: false,
|
||||
},
|
||||
'gpt-4o-mini'
|
||||
);
|
||||
|
||||
const result = await tools.code_artifact.execute?.(
|
||||
{ title: 'Demo', userPrompt: 'build a page' },
|
||||
{}
|
||||
);
|
||||
|
||||
t.like(result as object, { title: 'Demo' });
|
||||
Sinon.assert.calledOnceWithMatch(
|
||||
promptRuntime.runText,
|
||||
'Code Artifact',
|
||||
{ content: 'build a page' },
|
||||
{
|
||||
providerOptions: {
|
||||
user: 'user-1',
|
||||
session: 'session-1',
|
||||
workspace: 'workspace-1',
|
||||
byokLeaseId: 'lease-1',
|
||||
featureKind: 'chat',
|
||||
quotaBackedRoutesAllowed: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('ResponsePostprocessor should build text, object and image assistant turns', t => {
|
||||
const postprocessor = new ResponsePostprocessor();
|
||||
|
||||
@@ -267,7 +634,7 @@ test('action result projection should map image result url to assistant attachme
|
||||
t.deepEqual(turn?.attachments, ['https://example.com/final.png']);
|
||||
});
|
||||
|
||||
test('CopilotEmbeddingClientService should refresh configured client and clear unavailable client', async t => {
|
||||
test('CopilotEmbeddingClientService should keep dispatch client across global config refreshes', async t => {
|
||||
const taskPolicy = {
|
||||
resolveEmbeddingModelId: () => 'text-embedding-3-large',
|
||||
};
|
||||
@@ -288,8 +655,8 @@ test('CopilotEmbeddingClientService should refresh configured client and clear u
|
||||
t.truthy(service.getClient());
|
||||
|
||||
const second = await service.refresh();
|
||||
t.is(second, undefined);
|
||||
t.is(service.getClient(), undefined);
|
||||
t.truthy(second);
|
||||
t.is(service.getClient(), second);
|
||||
Sinon.assert.calledTwice(runtime.embeddingConfigured);
|
||||
Sinon.assert.alwaysCalledWithExactly(
|
||||
runtime.embeddingConfigured,
|
||||
@@ -297,6 +664,95 @@ test('CopilotEmbeddingClientService should refresh configured client and clear u
|
||||
);
|
||||
});
|
||||
|
||||
test('CopilotEmbeddingClientService should keep workspace-routed embedding client without global provider', async t => {
|
||||
const taskPolicy = {
|
||||
resolveEmbeddingModelId: () => 'gemini-embedding-001',
|
||||
resolveRerankModelId: () => 'gpt-4o-mini',
|
||||
};
|
||||
const runtime = {
|
||||
embeddingConfigured: Sinon.stub().resolves(false),
|
||||
};
|
||||
const service = new CopilotEmbeddingClientService(
|
||||
taskPolicy as any,
|
||||
runtime as any
|
||||
);
|
||||
|
||||
const client = await service.refresh();
|
||||
|
||||
t.truthy(client);
|
||||
t.is(service.getClient(), client);
|
||||
Sinon.assert.calledOnceWithExactly(
|
||||
runtime.embeddingConfigured,
|
||||
'gemini-embedding-001'
|
||||
);
|
||||
});
|
||||
|
||||
test('CopilotEmbeddingClientService should pass workspace context into embedding routes', async t => {
|
||||
const signal = new AbortController().signal;
|
||||
const taskPolicy = {
|
||||
resolveEmbeddingModelId: () => 'gemini-embedding-001',
|
||||
resolveRerankModelId: () => 'gpt-4o-mini',
|
||||
};
|
||||
const runtime = {
|
||||
embeddingConfigured: Sinon.stub().resolves(true),
|
||||
embed: Sinon.stub().resolves([[0.1]]),
|
||||
rerank: Sinon.stub().resolves([0.8]),
|
||||
};
|
||||
const service = new CopilotEmbeddingClientService(
|
||||
taskPolicy as any,
|
||||
runtime as any
|
||||
);
|
||||
const client = await service.refresh();
|
||||
|
||||
t.truthy(client);
|
||||
await client?.getEmbeddings(['hello'], {
|
||||
workspaceId: 'workspace-1',
|
||||
userId: 'user-1',
|
||||
featureKind: 'workspace_indexing',
|
||||
signal,
|
||||
});
|
||||
|
||||
Sinon.assert.calledOnceWithMatch(
|
||||
runtime.embed,
|
||||
'gemini-embedding-001',
|
||||
['hello'],
|
||||
{
|
||||
dimensions: Sinon.match.number,
|
||||
workspace: 'workspace-1',
|
||||
user: 'user-1',
|
||||
featureKind: 'workspace_indexing',
|
||||
signal,
|
||||
}
|
||||
);
|
||||
|
||||
await client?.reRank(
|
||||
'hello',
|
||||
[{ chunk: 0, content: 'hello', distance: 0.2 }],
|
||||
1,
|
||||
{
|
||||
workspaceId: 'workspace-1',
|
||||
userId: 'user-1',
|
||||
featureKind: 'workspace_indexing',
|
||||
signal,
|
||||
}
|
||||
);
|
||||
|
||||
Sinon.assert.calledOnceWithMatch(
|
||||
runtime.rerank,
|
||||
'gpt-4o-mini',
|
||||
{
|
||||
query: 'hello',
|
||||
candidates: [{ id: '0', text: 'hello' }],
|
||||
},
|
||||
{
|
||||
workspace: 'workspace-1',
|
||||
user: 'user-1',
|
||||
featureKind: 'rerank',
|
||||
signal,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('CompatHistoryProjector should compose visibility, prompt preload and attachment url projection', t => {
|
||||
const projector = new CompatHistoryProjector(
|
||||
new HistoryVisibilityPolicy(),
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
import { GeminiProvider } from '../../plugins/copilot/providers/gemini/gemini';
|
||||
import { GeminiVertexProvider } from '../../plugins/copilot/providers/gemini/vertex';
|
||||
import { OpenAIProvider } from '../../plugins/copilot/providers/openai';
|
||||
import { PerplexityProvider } from '../../plugins/copilot/providers/perplexity';
|
||||
import {
|
||||
CopilotProviderType,
|
||||
type PromptMessage,
|
||||
@@ -589,16 +588,6 @@ class TestOpenAIProvider extends OpenAIProvider {
|
||||
}
|
||||
}
|
||||
|
||||
class TestPerplexityProvider extends PerplexityProvider {
|
||||
override get config() {
|
||||
return { apiKey: 'perplexity-key' };
|
||||
}
|
||||
|
||||
override configured() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
test('NativeProviderAdapter should append citation and attachment footnotes', async t => {
|
||||
const dispatch = () =>
|
||||
(async function* (): AsyncIterableIterator<LlmToolLoopStreamEvent> {
|
||||
@@ -818,6 +807,91 @@ test('NativeProviderAdapter streamObject should map tool and text events', async
|
||||
t.snapshot(events);
|
||||
});
|
||||
|
||||
test('NativeProviderAdapter streamObject should finalize usage with selected provider', async t => {
|
||||
const usageEvents: Array<{
|
||||
providerId: string;
|
||||
model?: string;
|
||||
usage?: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
cached_tokens?: number;
|
||||
};
|
||||
}> = [];
|
||||
const adapter = new NativeProviderAdapter(
|
||||
() =>
|
||||
stream(() => [
|
||||
{ type: 'message_start', model: 'gpt-5-mini' },
|
||||
{ type: 'text_delta', text: 'ok' },
|
||||
{
|
||||
type: 'done',
|
||||
finish_reason: 'stop',
|
||||
usage: { prompt_tokens: 2, completion_tokens: 3, total_tokens: 5 },
|
||||
},
|
||||
{
|
||||
type: 'provider_selected',
|
||||
provider_id: 'byok-aaaaaaaaaaaa-openai-server-key1',
|
||||
},
|
||||
]),
|
||||
{
|
||||
onUsage: input => {
|
||||
usageEvents.push(input);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const events = await collectChunks(
|
||||
adapter.streamObject({
|
||||
model: 'gpt-5-mini',
|
||||
stream: true,
|
||||
messages: nativeMessages(nativeUserText('hi')),
|
||||
})
|
||||
);
|
||||
|
||||
t.deepEqual(events, [{ type: 'text-delta', textDelta: 'ok' }]);
|
||||
t.deepEqual(usageEvents, [
|
||||
{
|
||||
providerId: 'byok-aaaaaaaaaaaa-openai-server-key1',
|
||||
model: 'gpt-5-mini',
|
||||
usage: { prompt_tokens: 2, completion_tokens: 3, total_tokens: 5 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('NativeProviderAdapter streamObject should keep streaming when usage callback fails', async t => {
|
||||
const adapter = new NativeProviderAdapter(
|
||||
() =>
|
||||
stream(() => [
|
||||
{ type: 'message_start', model: 'gpt-5-mini' },
|
||||
{ type: 'text_delta', text: 'ok' },
|
||||
{
|
||||
type: 'done',
|
||||
finish_reason: 'stop',
|
||||
usage: { prompt_tokens: 2, completion_tokens: 3, total_tokens: 5 },
|
||||
},
|
||||
{
|
||||
type: 'provider_selected',
|
||||
provider_id: 'byok-aaaaaaaaaaaa-openai-server-key1',
|
||||
},
|
||||
]),
|
||||
{
|
||||
onUsage: () => {
|
||||
throw new Error('usage callback failed');
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const events = await collectChunks(
|
||||
adapter.streamObject({
|
||||
model: 'gpt-5-mini',
|
||||
stream: true,
|
||||
messages: nativeMessages(nativeUserText('hi')),
|
||||
})
|
||||
);
|
||||
|
||||
t.deepEqual(events, [{ type: 'text-delta', textDelta: 'ok' }]);
|
||||
});
|
||||
|
||||
test('NativeRuntimeAdapter streamObject should keep raw runtime stream objects only', async t => {
|
||||
const adapter = new NativeRuntimeAdapter(
|
||||
createTestToolLoopBridge(mockDispatch, {}, 3)
|
||||
@@ -1653,36 +1727,6 @@ test('GeminiProvider should not pass materialized inline attachment URL to nativ
|
||||
t.false('url' in (attachmentPart?.source ?? {}));
|
||||
});
|
||||
|
||||
test('PerplexityProvider should ignore attachments during text model matching', async t => {
|
||||
const provider = new TestPerplexityProvider();
|
||||
let capturedRequest: LlmRequest | undefined;
|
||||
|
||||
(provider as any).getActiveProviderMiddleware = () => ({});
|
||||
(provider as any).getTools = async () => ({});
|
||||
(provider as any).createNativeAdapter = () => ({
|
||||
text: async (request: LlmRequest) => {
|
||||
capturedRequest = request;
|
||||
return 'ok';
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getProviderRuntimeHost(provider).run.text(
|
||||
{ modelId: 'sonar' },
|
||||
[
|
||||
{
|
||||
role: 'user',
|
||||
content: 'summarize this',
|
||||
attachments: ['https://example.com/a.pdf'],
|
||||
params: { mimetype: 'application/pdf' },
|
||||
},
|
||||
],
|
||||
{}
|
||||
);
|
||||
|
||||
t.is(result, 'ok');
|
||||
t.snapshot(capturedRequest?.messages[0]?.content);
|
||||
});
|
||||
|
||||
test('GeminiProvider should reject unsupported attachment schemes at input validation', async t => {
|
||||
const provider = new TestGeminiProvider();
|
||||
|
||||
|
||||
@@ -3,7 +3,11 @@ import test from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CopilotPromptInvalid, NoCopilotProviderAvailable } from '../../base';
|
||||
import {
|
||||
CopilotPromptInvalid,
|
||||
CopilotQuotaExceeded,
|
||||
NoCopilotProviderAvailable,
|
||||
} from '../../base';
|
||||
import {
|
||||
type LlmBackendConfig,
|
||||
type LlmEmbeddingRequest,
|
||||
@@ -20,9 +24,11 @@ import {
|
||||
llmResolveRequestedModelMatch,
|
||||
type LlmStructuredRequest,
|
||||
} from '../../native';
|
||||
import type { ProviderMiddlewareConfig } from '../../plugins/copilot/config';
|
||||
import type {
|
||||
CopilotProviderProfile,
|
||||
ProviderMiddlewareConfig,
|
||||
} from '../../plugins/copilot/config';
|
||||
import { CopilotProviderFactory } from '../../plugins/copilot/providers/factory';
|
||||
import { MorphProvider } from '../../plugins/copilot/providers/morph';
|
||||
import { OpenAIProvider } from '../../plugins/copilot/providers/openai';
|
||||
import { CopilotProvider } from '../../plugins/copilot/providers/provider';
|
||||
import { buildProviderRegistry } from '../../plugins/copilot/providers/provider-registry';
|
||||
@@ -62,6 +68,13 @@ import {
|
||||
userPrompt,
|
||||
} from './prompt-test-helper';
|
||||
|
||||
function createNativeExecutionEngine() {
|
||||
return new NativeExecutionEngine({
|
||||
recordUsage: Sinon.stub().resolves(),
|
||||
recordProviderFailure: Sinon.stub().resolves(),
|
||||
} as never);
|
||||
}
|
||||
|
||||
function structuredOptions(
|
||||
schema: z.ZodTypeAny,
|
||||
extra?: Record<string, unknown>
|
||||
@@ -541,7 +554,7 @@ test('CapabilityRuntime should defer no-route embedding plans to native engine',
|
||||
});
|
||||
|
||||
test('NativeExecutionEngine should expose execute/executeStream as the single plan entrypoints', async t => {
|
||||
const engine = new NativeExecutionEngine();
|
||||
const engine = createNativeExecutionEngine();
|
||||
let dispatchCalls = 0;
|
||||
let streamCalls = 0;
|
||||
|
||||
@@ -660,6 +673,279 @@ test('NativeExecutionEngine should expose execute/executeStream as the single pl
|
||||
t.is(streamCalls, 1);
|
||||
});
|
||||
|
||||
test('NativeExecutionEngine should record BYOK usage when stream finalizes with selected provider', async t => {
|
||||
const byok = {
|
||||
recordUsage: Sinon.stub().resolves(),
|
||||
};
|
||||
const engine = new NativeExecutionEngine(byok as never);
|
||||
const providerId = 'byok-aaaaaaaaaaaa-openai-server-key1';
|
||||
|
||||
const originalStream = (serverNativeModule as any).llmDispatchPreparedStream;
|
||||
(serverNativeModule as any).llmDispatchPreparedStream = (
|
||||
_routesJson: string,
|
||||
callback: (error: Error | null, arg: string) => void
|
||||
) => {
|
||||
callback(
|
||||
null,
|
||||
JSON.stringify({
|
||||
type: 'message_start',
|
||||
model: 'gpt-5-mini',
|
||||
})
|
||||
);
|
||||
callback(null, JSON.stringify({ type: 'text_delta', text: 'ok' }));
|
||||
callback(
|
||||
null,
|
||||
JSON.stringify({
|
||||
type: 'done',
|
||||
finish_reason: 'stop',
|
||||
usage: {
|
||||
prompt_tokens: 2,
|
||||
completion_tokens: 3,
|
||||
total_tokens: 5,
|
||||
},
|
||||
})
|
||||
);
|
||||
callback(
|
||||
null,
|
||||
JSON.stringify({
|
||||
type: 'provider_selected',
|
||||
provider_id: providerId,
|
||||
})
|
||||
);
|
||||
callback(null, '__AFFINE_LLM_STREAM_END__');
|
||||
return { abort() {} };
|
||||
};
|
||||
t.teardown(() => {
|
||||
(serverNativeModule as any).llmDispatchPreparedStream = originalStream;
|
||||
});
|
||||
|
||||
const chunks = await collectAsync(
|
||||
engine.executeStream({
|
||||
nativeDispatch: {
|
||||
chat: {
|
||||
routes: [
|
||||
nativeRoute({
|
||||
providerId,
|
||||
authToken: 'byok-key',
|
||||
request: nativeTextRequest('hello'),
|
||||
}),
|
||||
],
|
||||
prepared: {
|
||||
route: preparedRoute({
|
||||
providerId,
|
||||
authToken: 'byok-key',
|
||||
}),
|
||||
request: nativeTextRequest('hello'),
|
||||
tools: {},
|
||||
postprocess: { nodeTextMiddleware: [] },
|
||||
},
|
||||
hasTools: false,
|
||||
},
|
||||
},
|
||||
request: {
|
||||
kind: 'streamText',
|
||||
cond: { modelId: 'gpt-5-mini' },
|
||||
messages: singleUserPromptMessages('hello'),
|
||||
options: {
|
||||
workspace: 'workspace-1',
|
||||
user: 'user-1',
|
||||
session: 'session-1',
|
||||
featureKind: 'chat',
|
||||
},
|
||||
},
|
||||
routePolicy: { fallbackOrder: [providerId] },
|
||||
runtimePolicy: {},
|
||||
attachmentPolicy: { materializeRemoteAttachments: true },
|
||||
responsePostprocess: { mode: 'streamText' },
|
||||
hostPersistence: { persistAssistantTurn: true, outputKind: 'streamText' },
|
||||
hostContext: {},
|
||||
})
|
||||
);
|
||||
|
||||
t.deepEqual(chunks, ['ok']);
|
||||
Sinon.assert.calledOnceWithMatch(byok.recordUsage, {
|
||||
workspaceId: 'workspace-1',
|
||||
userId: 'user-1',
|
||||
sessionId: 'session-1',
|
||||
featureKind: 'chat',
|
||||
providerId,
|
||||
model: 'gpt-5-mini',
|
||||
usage: {
|
||||
prompt_tokens: 2,
|
||||
completion_tokens: 3,
|
||||
total_tokens: 5,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('NativeExecutionEngine should record plain text BYOK usage as chat by default', async t => {
|
||||
const byok = {
|
||||
recordUsage: Sinon.stub().resolves(),
|
||||
recordProviderFailure: Sinon.stub().resolves(),
|
||||
};
|
||||
const engine = new NativeExecutionEngine(byok as never);
|
||||
const providerId = 'byok-aaaaaaaaaaaa-openai-server-key1';
|
||||
|
||||
const originalDispatch = (serverNativeModule as any).llmDispatchPrepared;
|
||||
(serverNativeModule as any).llmDispatchPrepared = () => {
|
||||
return JSON.stringify({
|
||||
provider_id: providerId,
|
||||
response: {
|
||||
id: 'chat_execute',
|
||||
model: 'gpt-5-mini',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'execute-ok' }],
|
||||
},
|
||||
usage: {
|
||||
prompt_tokens: 1,
|
||||
completion_tokens: 2,
|
||||
total_tokens: 3,
|
||||
},
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
});
|
||||
};
|
||||
t.teardown(() => {
|
||||
(serverNativeModule as any).llmDispatchPrepared = originalDispatch;
|
||||
});
|
||||
|
||||
const text = await engine.execute({
|
||||
nativeDispatch: {
|
||||
chat: {
|
||||
routes: [
|
||||
nativeRoute({
|
||||
providerId,
|
||||
authToken: 'byok-key',
|
||||
request: nativeTextRequest('hello'),
|
||||
}),
|
||||
],
|
||||
prepared: {
|
||||
route: preparedRoute({
|
||||
providerId,
|
||||
authToken: 'byok-key',
|
||||
}),
|
||||
request: nativeTextRequest('hello'),
|
||||
tools: {},
|
||||
postprocess: { nodeTextMiddleware: [] },
|
||||
},
|
||||
hasTools: false,
|
||||
},
|
||||
},
|
||||
request: {
|
||||
kind: 'text',
|
||||
cond: { modelId: 'gpt-5-mini' },
|
||||
messages: singleUserPromptMessages('hello'),
|
||||
options: {
|
||||
workspace: 'workspace-1',
|
||||
user: 'user-1',
|
||||
session: 'session-1',
|
||||
},
|
||||
},
|
||||
routePolicy: { fallbackOrder: [providerId] },
|
||||
runtimePolicy: {},
|
||||
attachmentPolicy: { materializeRemoteAttachments: true },
|
||||
responsePostprocess: { mode: 'text' },
|
||||
hostPersistence: { persistAssistantTurn: true, outputKind: 'text' },
|
||||
hostContext: {},
|
||||
});
|
||||
|
||||
t.is(text, 'execute-ok');
|
||||
Sinon.assert.calledOnceWithMatch(byok.recordUsage, {
|
||||
workspaceId: 'workspace-1',
|
||||
userId: 'user-1',
|
||||
sessionId: 'session-1',
|
||||
featureKind: 'chat',
|
||||
providerId,
|
||||
model: 'gpt-5-mini',
|
||||
usage: {
|
||||
prompt_tokens: 1,
|
||||
completion_tokens: 2,
|
||||
total_tokens: 3,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('NativeExecutionEngine should not fail stream when BYOK usage recording fails', async t => {
|
||||
const byok = {
|
||||
recordUsage: Sinon.stub().rejects(new Error('usage db down')),
|
||||
};
|
||||
const engine = new NativeExecutionEngine(byok as never);
|
||||
const providerId = 'byok-aaaaaaaaaaaa-openai-server-key1';
|
||||
|
||||
const originalStream = (serverNativeModule as any).llmDispatchPreparedStream;
|
||||
(serverNativeModule as any).llmDispatchPreparedStream = (
|
||||
_routesJson: string,
|
||||
callback: (error: Error | null, arg: string) => void
|
||||
) => {
|
||||
callback(
|
||||
null,
|
||||
JSON.stringify({ type: 'message_start', model: 'gpt-5-mini' })
|
||||
);
|
||||
callback(null, JSON.stringify({ type: 'text_delta', text: 'ok' }));
|
||||
callback(
|
||||
null,
|
||||
JSON.stringify({
|
||||
type: 'done',
|
||||
finish_reason: 'stop',
|
||||
usage: { prompt_tokens: 2, completion_tokens: 3, total_tokens: 5 },
|
||||
})
|
||||
);
|
||||
callback(
|
||||
null,
|
||||
JSON.stringify({ type: 'provider_selected', provider_id: providerId })
|
||||
);
|
||||
callback(null, '__AFFINE_LLM_STREAM_END__');
|
||||
return { abort() {} };
|
||||
};
|
||||
t.teardown(() => {
|
||||
(serverNativeModule as any).llmDispatchPreparedStream = originalStream;
|
||||
});
|
||||
|
||||
const chunks = await collectAsync(
|
||||
engine.executeStream({
|
||||
nativeDispatch: {
|
||||
chat: {
|
||||
routes: [
|
||||
nativeRoute({
|
||||
providerId,
|
||||
authToken: 'byok-key',
|
||||
request: nativeTextRequest('hello'),
|
||||
}),
|
||||
],
|
||||
prepared: {
|
||||
route: preparedRoute({ providerId, authToken: 'byok-key' }),
|
||||
request: nativeTextRequest('hello'),
|
||||
tools: {},
|
||||
postprocess: { nodeTextMiddleware: [] },
|
||||
},
|
||||
hasTools: false,
|
||||
},
|
||||
},
|
||||
request: {
|
||||
kind: 'streamText',
|
||||
cond: { modelId: 'gpt-5-mini' },
|
||||
messages: singleUserPromptMessages('hello'),
|
||||
options: {
|
||||
workspace: 'workspace-1',
|
||||
user: 'user-1',
|
||||
session: 'session-1',
|
||||
featureKind: 'chat',
|
||||
},
|
||||
},
|
||||
routePolicy: { fallbackOrder: [providerId] },
|
||||
runtimePolicy: {},
|
||||
attachmentPolicy: { materializeRemoteAttachments: true },
|
||||
responsePostprocess: { mode: 'streamText' },
|
||||
hostPersistence: { persistAssistantTurn: true, outputKind: 'streamText' },
|
||||
hostContext: {},
|
||||
})
|
||||
);
|
||||
|
||||
t.deepEqual(chunks, ['ok']);
|
||||
Sinon.assert.calledOnce(byok.recordUsage);
|
||||
});
|
||||
|
||||
test('CopilotProviderFactory should return no prepared routes when native prepare returns null', async t => {
|
||||
const provider = new DriverOnlyProvider();
|
||||
(provider as any).AFFiNEConfig = { copilot: { providers: { openai: {} } } };
|
||||
@@ -693,9 +979,16 @@ test('CopilotProviderFactory should return no prepared routes when native prepar
|
||||
enableFeature: Sinon.stub(),
|
||||
disableFeature: Sinon.stub(),
|
||||
};
|
||||
const access = {
|
||||
resolveRouteAccess: Sinon.stub().resolves({
|
||||
byokProfiles: [],
|
||||
quotaBackedRoutesAvailable: true,
|
||||
}),
|
||||
};
|
||||
const factory = new CopilotProviderFactory(
|
||||
server as never,
|
||||
registryService as never
|
||||
registryService as never,
|
||||
access as never
|
||||
);
|
||||
factory.register('openai-main', provider);
|
||||
|
||||
@@ -923,52 +1216,6 @@ test('driver-only provider should require explicit structured response contracts
|
||||
t.is(capturedRequest, undefined);
|
||||
});
|
||||
|
||||
test('MorphProvider should reuse the base native chat driver template', async t => {
|
||||
const provider = new MorphProvider();
|
||||
(provider as any).AFFiNEConfig = {
|
||||
copilot: { providers: { morph: { apiKey: 'test-key' } } },
|
||||
};
|
||||
(provider as any).toolExecutorHost = {
|
||||
createNativeAdapter: () => ({
|
||||
text: async () => 'morph text',
|
||||
streamText: async function* () {
|
||||
yield 'morph stream';
|
||||
},
|
||||
streamObject: async function* () {
|
||||
yield { type: 'text-delta', textDelta: 'unused' };
|
||||
},
|
||||
}),
|
||||
getTools: async () => ({}),
|
||||
};
|
||||
|
||||
t.is(
|
||||
await getProviderRuntimeHost(provider).run.text(
|
||||
{ modelId: 'morph-v3-fast' },
|
||||
promptMessages(userPrompt('hello'))
|
||||
),
|
||||
'morph text'
|
||||
);
|
||||
t.deepEqual(
|
||||
await collectAsync(
|
||||
getProviderRuntimeHost(provider).run.streamText(
|
||||
{ modelId: 'morph-v3-fast' },
|
||||
promptMessages(userPrompt('hello'))
|
||||
)
|
||||
),
|
||||
['morph stream']
|
||||
);
|
||||
t.is(
|
||||
await getProviderRuntimeHost(provider).prepare.chat(
|
||||
'streamObject',
|
||||
{
|
||||
modelId: 'morph-v3-fast',
|
||||
},
|
||||
promptMessages(userPrompt('hello'))
|
||||
),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test('getActiveProviderMiddleware should merge defaults with profile override', t => {
|
||||
const provider = createProvider({
|
||||
rust: { request: ['clamp_max_tokens'] },
|
||||
@@ -1231,9 +1478,16 @@ test('CopilotProviderFactory should resolve legacy model ids through native regi
|
||||
enableFeature: Sinon.stub(),
|
||||
disableFeature: Sinon.stub(),
|
||||
};
|
||||
const access = {
|
||||
resolveRouteAccess: Sinon.stub().resolves({
|
||||
byokProfiles: [],
|
||||
quotaBackedRoutesAvailable: true,
|
||||
}),
|
||||
};
|
||||
const factory = new CopilotProviderFactory(
|
||||
server as never,
|
||||
registryService as never
|
||||
registryService as never,
|
||||
access as never
|
||||
);
|
||||
factory.register('openai-main', provider);
|
||||
|
||||
@@ -1242,6 +1496,230 @@ test('CopilotProviderFactory should resolve legacy model ids through native regi
|
||||
t.is(provider.resolveModel('gpt-5-2025-08-07')?.id, 'gpt-5');
|
||||
});
|
||||
|
||||
const BYOK_OPENAI_PROFILE: CopilotProviderProfile = {
|
||||
id: 'byok-aaaaaaaaaaaa-openai-server-key1',
|
||||
type: CopilotProviderType.OpenAI,
|
||||
priority: 10_000,
|
||||
config: { apiKey: 'byok-key' },
|
||||
};
|
||||
|
||||
const BYOK_FAL_PROFILE: CopilotProviderProfile = {
|
||||
id: 'byok-aaaaaaaaaaaa-fal-server-key1',
|
||||
type: CopilotProviderType.FAL,
|
||||
priority: 10_000,
|
||||
config: { apiKey: 'byok-key' },
|
||||
};
|
||||
|
||||
function createProviderFactoryWithByokRoutes({
|
||||
byokProfiles = [BYOK_OPENAI_PROFILE],
|
||||
hasQuota = true,
|
||||
}: {
|
||||
byokProfiles?: CopilotProviderProfile[];
|
||||
hasQuota?: boolean;
|
||||
} = {}) {
|
||||
const provider = createProvider();
|
||||
const registryService = {
|
||||
getRegistry: () =>
|
||||
buildProviderRegistry({
|
||||
profiles: [
|
||||
{
|
||||
id: 'openai-main',
|
||||
type: CopilotProviderType.OpenAI,
|
||||
priority: 1,
|
||||
config: { apiKey: 'test-key' },
|
||||
},
|
||||
],
|
||||
defaults: {},
|
||||
}),
|
||||
};
|
||||
const server = {
|
||||
enableFeature: Sinon.stub(),
|
||||
disableFeature: Sinon.stub(),
|
||||
};
|
||||
const byok = {
|
||||
getProfiles: Sinon.stub().resolves(byokProfiles),
|
||||
};
|
||||
const access = {
|
||||
resolveRouteAccess: Sinon.stub().callsFake(async context => ({
|
||||
byokProfiles: await byok.getProfiles(context),
|
||||
quotaBackedRoutesAvailable: context.quotaBackedRoutesAllowed ?? hasQuota,
|
||||
})),
|
||||
};
|
||||
const factory = new CopilotProviderFactory(
|
||||
server as never,
|
||||
registryService as never,
|
||||
access as never
|
||||
);
|
||||
factory.register('openai-main', provider);
|
||||
|
||||
return { factory, byok };
|
||||
}
|
||||
|
||||
test('CopilotProviderFactory should use matching BYOK routes before quota-backed routes', async t => {
|
||||
const { factory } = createProviderFactoryWithByokRoutes();
|
||||
|
||||
const routes = await factory.resolveRoutes(
|
||||
{ modelId: 'gpt-5-mini', outputType: ModelOutputType.Text },
|
||||
{},
|
||||
{ userId: 'user-1', workspaceId: 'workspace-1' }
|
||||
);
|
||||
|
||||
t.deepEqual(
|
||||
routes.map(route => route.providerId),
|
||||
['byok-aaaaaaaaaaaa-openai-server-key1']
|
||||
);
|
||||
});
|
||||
|
||||
test('CopilotProviderFactory should skip unsupported BYOK profiles and use quota-backed fallback', async t => {
|
||||
const { factory } = createProviderFactoryWithByokRoutes({
|
||||
byokProfiles: [BYOK_FAL_PROFILE],
|
||||
});
|
||||
|
||||
const routes = await factory.resolveRoutes(
|
||||
{ modelId: 'gpt-5-mini', outputType: ModelOutputType.Text },
|
||||
{},
|
||||
{ userId: 'user-1', workspaceId: 'workspace-1' }
|
||||
);
|
||||
|
||||
t.deepEqual(
|
||||
routes.map(route => route.providerId),
|
||||
['openai-main']
|
||||
);
|
||||
});
|
||||
|
||||
test('CopilotProviderFactory should resolve BYOK embedding routes with workspace context', async t => {
|
||||
const { factory, byok } = createProviderFactoryWithByokRoutes();
|
||||
|
||||
const routes = await factory.resolveRoutes(
|
||||
{
|
||||
modelId: 'text-embedding-3-small',
|
||||
outputType: ModelOutputType.Embedding,
|
||||
},
|
||||
{},
|
||||
{ workspaceId: 'workspace-1', featureKind: 'workspace_indexing' }
|
||||
);
|
||||
|
||||
t.deepEqual(
|
||||
routes.map(route => route.providerId),
|
||||
['byok-aaaaaaaaaaaa-openai-server-key1']
|
||||
);
|
||||
Sinon.assert.calledOnceWithMatch(byok.getProfiles, {
|
||||
workspaceId: 'workspace-1',
|
||||
featureKind: 'workspace_indexing',
|
||||
});
|
||||
});
|
||||
|
||||
test('CopilotProviderFactory should treat embedding preparation as embedding feature by default', async t => {
|
||||
const { factory, byok } = createProviderFactoryWithByokRoutes();
|
||||
|
||||
await factory.prepareEmbeddingRoutes('text-embedding-3-small', 'hello', {
|
||||
workspace: 'workspace-1',
|
||||
});
|
||||
|
||||
t.true(byok.getProfiles.calledOnce);
|
||||
Sinon.assert.calledOnceWithMatch(byok.getProfiles, {
|
||||
workspaceId: 'workspace-1',
|
||||
featureKind: 'embedding',
|
||||
});
|
||||
});
|
||||
|
||||
test('CopilotProviderFactory should resolve BYOK rerank routes before quota-backed routes', async t => {
|
||||
const { factory, byok } = createProviderFactoryWithByokRoutes();
|
||||
|
||||
const preparedRoutes = await factory.prepareRerankRoutes(
|
||||
'gpt-4o-mini',
|
||||
{
|
||||
query: 'programming',
|
||||
candidates: [{ text: 'React is a UI library.' }],
|
||||
},
|
||||
{ workspace: 'workspace-1' }
|
||||
);
|
||||
const resolvedRoutes = await factory.resolveRoutes(
|
||||
{ modelId: 'gpt-4o-mini', outputType: ModelOutputType.Rerank },
|
||||
{},
|
||||
{ workspaceId: 'workspace-1', featureKind: 'rerank' }
|
||||
);
|
||||
|
||||
t.deepEqual(
|
||||
preparedRoutes.map(route => route.providerId),
|
||||
[]
|
||||
);
|
||||
t.deepEqual(
|
||||
resolvedRoutes.map(route => route.providerId),
|
||||
['byok-aaaaaaaaaaaa-openai-server-key1']
|
||||
);
|
||||
Sinon.assert.calledWithMatch(byok.getProfiles, {
|
||||
workspaceId: 'workspace-1',
|
||||
featureKind: 'rerank',
|
||||
});
|
||||
});
|
||||
|
||||
test('CopilotProviderFactory should treat image preparation as image feature by default', async t => {
|
||||
const { factory, byok } = createProviderFactoryWithByokRoutes();
|
||||
|
||||
await factory.prepareImageRoutes(
|
||||
{ modelId: 'gpt-image-1', outputType: ModelOutputType.Image },
|
||||
singleUserPromptMessages('draw a cat'),
|
||||
{ workspace: 'workspace-1' }
|
||||
);
|
||||
|
||||
t.true(byok.getProfiles.calledOnce);
|
||||
Sinon.assert.calledOnceWithMatch(byok.getProfiles, {
|
||||
workspaceId: 'workspace-1',
|
||||
featureKind: 'image',
|
||||
});
|
||||
});
|
||||
|
||||
test('CopilotProviderFactory should omit quota-backed routes when quota is exhausted', async t => {
|
||||
const { factory } = createProviderFactoryWithByokRoutes({ hasQuota: false });
|
||||
|
||||
const routes = await factory.resolveRoutes(
|
||||
{ modelId: 'gpt-5-mini', outputType: ModelOutputType.Text },
|
||||
{},
|
||||
{ userId: 'user-1', workspaceId: 'workspace-1' }
|
||||
);
|
||||
|
||||
t.deepEqual(
|
||||
routes.map(route => route.providerId),
|
||||
['byok-aaaaaaaaaaaa-openai-server-key1']
|
||||
);
|
||||
});
|
||||
|
||||
test('CopilotProviderFactory should raise quota exceeded when only quota-backed routes match', async t => {
|
||||
const { factory } = createProviderFactoryWithByokRoutes({
|
||||
byokProfiles: [],
|
||||
hasQuota: false,
|
||||
});
|
||||
|
||||
await t.throwsAsync(
|
||||
factory.resolveRoutes(
|
||||
{ modelId: 'gpt-5-mini', outputType: ModelOutputType.Text },
|
||||
{},
|
||||
{ userId: 'user-1', workspaceId: 'workspace-1' }
|
||||
),
|
||||
{ instanceOf: CopilotQuotaExceeded }
|
||||
);
|
||||
});
|
||||
|
||||
test('CopilotProviderFactory should not report quota exhausted when quota-backed routes are disabled', async t => {
|
||||
const { factory } = createProviderFactoryWithByokRoutes({
|
||||
byokProfiles: [],
|
||||
hasQuota: true,
|
||||
});
|
||||
|
||||
const routes = await factory.resolveRoutes(
|
||||
{ modelId: 'gpt-5-mini', outputType: ModelOutputType.Text },
|
||||
{},
|
||||
{
|
||||
userId: 'user-1',
|
||||
workspaceId: 'workspace-1',
|
||||
quotaBackedRoutesAllowed: false,
|
||||
}
|
||||
);
|
||||
|
||||
t.deepEqual(routes, []);
|
||||
});
|
||||
|
||||
test('selectModel should reject unknown models without online fallback', t => {
|
||||
const provider = new TestOpenAIProvider();
|
||||
t.is(provider.resolveModel('online-preview'), undefined);
|
||||
@@ -1476,7 +1954,7 @@ test('ProviderDriverSpec should freeze declarative driver shape', t => {
|
||||
});
|
||||
|
||||
test('NativeExecutionEngine should dispatch prepared text routes through native fallback', async t => {
|
||||
const engine = new NativeExecutionEngine();
|
||||
const engine = createNativeExecutionEngine();
|
||||
const registry = buildProviderRegistry({
|
||||
profiles: [
|
||||
{
|
||||
@@ -1575,8 +2053,78 @@ test('NativeExecutionEngine should dispatch prepared text routes through native
|
||||
t.snapshot(summarizePreparedDispatchRoutes(capturedRoutes));
|
||||
});
|
||||
|
||||
test('NativeExecutionEngine should record single BYOK route dispatch failure', async t => {
|
||||
const byok = {
|
||||
recordProviderFailure: Sinon.stub().resolves(),
|
||||
recordUsage: Sinon.stub().resolves(),
|
||||
};
|
||||
const engine = new NativeExecutionEngine(byok as never);
|
||||
const providerId = 'byok-aaaaaaaaaaaa-openai-server-key1';
|
||||
|
||||
const original = (serverNativeModule as any).llmDispatchPrepared;
|
||||
(serverNativeModule as any).llmDispatchPrepared = () => {
|
||||
throw new Error('401 invalid sk-test-primary');
|
||||
};
|
||||
t.teardown(() => {
|
||||
(serverNativeModule as any).llmDispatchPrepared = original;
|
||||
});
|
||||
|
||||
const error = await t.throwsAsync(
|
||||
engine.execute({
|
||||
nativeDispatch: {
|
||||
chat: {
|
||||
routes: [
|
||||
nativeRoute({
|
||||
providerId,
|
||||
authToken: 'primary-key',
|
||||
request: nativeTextRequest('hello'),
|
||||
}),
|
||||
],
|
||||
prepared: {
|
||||
route: preparedRoute({
|
||||
providerId,
|
||||
authToken: 'primary-key',
|
||||
}),
|
||||
request: nativeTextRequest('hello'),
|
||||
tools: {},
|
||||
postprocess: { nodeTextMiddleware: [] },
|
||||
},
|
||||
hasTools: false,
|
||||
},
|
||||
},
|
||||
request: {
|
||||
kind: 'text',
|
||||
cond: { modelId: 'gpt-5-mini' },
|
||||
messages: singleUserPromptMessages('hello'),
|
||||
options: {
|
||||
workspace: 'workspace-1',
|
||||
user: 'user-1',
|
||||
session: 'session-1',
|
||||
featureKind: 'chat',
|
||||
},
|
||||
},
|
||||
routePolicy: { fallbackOrder: [providerId] },
|
||||
runtimePolicy: {},
|
||||
attachmentPolicy: { materializeRemoteAttachments: true },
|
||||
responsePostprocess: { mode: 'text' },
|
||||
hostPersistence: { persistAssistantTurn: true, outputKind: 'text' },
|
||||
hostContext: {
|
||||
currentMessages: singleUserPromptMessages('hello'),
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
t.truthy(error);
|
||||
Sinon.assert.calledOnceWithMatch(byok.recordProviderFailure, {
|
||||
workspaceId: 'workspace-1',
|
||||
providerId,
|
||||
featureKind: 'chat',
|
||||
});
|
||||
Sinon.assert.notCalled(byok.recordUsage);
|
||||
});
|
||||
|
||||
test('NativeExecutionEngine should reject single-route plans when no native route is prepared', async t => {
|
||||
const engine = new NativeExecutionEngine();
|
||||
const engine = createNativeExecutionEngine();
|
||||
|
||||
const error = await t.throwsAsync(
|
||||
engine.execute({
|
||||
@@ -1604,7 +2152,7 @@ test('NativeExecutionEngine should reject single-route plans when no native rout
|
||||
});
|
||||
|
||||
test('NativeExecutionEngine should prefer prepared native fallback dispatch for explicit routes', async t => {
|
||||
const engine = new NativeExecutionEngine();
|
||||
const engine = createNativeExecutionEngine();
|
||||
let capturedRoutes: unknown;
|
||||
let called = false;
|
||||
|
||||
@@ -1683,7 +2231,7 @@ test('NativeExecutionEngine should prefer prepared native fallback dispatch for
|
||||
});
|
||||
|
||||
test('NativeExecutionEngine should stream through prepared native fallback dispatch', async t => {
|
||||
const engine = new NativeExecutionEngine();
|
||||
const engine = createNativeExecutionEngine();
|
||||
let called = false;
|
||||
|
||||
const original = (serverNativeModule as any).llmDispatchPreparedStream;
|
||||
@@ -1912,7 +2460,7 @@ test('ExecutionPlanBuilder should keep single-route tool chat plans on prepared_
|
||||
});
|
||||
|
||||
test('NativeExecutionEngine should route tool-loop chat prepared routes through native dispatch', async t => {
|
||||
const engine = new NativeExecutionEngine();
|
||||
const engine = createNativeExecutionEngine();
|
||||
let capturedRoutes: unknown;
|
||||
let called = false;
|
||||
let toolCallbackCount = 0;
|
||||
@@ -2262,7 +2810,7 @@ test('ExecutionPlanBuilder should build native prepared routes for structured, i
|
||||
});
|
||||
|
||||
test('NativeExecutionEngine should dispatch structured prepared routes through native execution', async t => {
|
||||
const engine = new NativeExecutionEngine();
|
||||
const engine = createNativeExecutionEngine();
|
||||
let capturedRoutes: unknown;
|
||||
let called = false;
|
||||
|
||||
@@ -2350,7 +2898,7 @@ test('NativeExecutionEngine should dispatch structured prepared routes through n
|
||||
});
|
||||
|
||||
test('NativeExecutionEngine should dispatch embedding prepared routes through native execution', async t => {
|
||||
const engine = new NativeExecutionEngine();
|
||||
const engine = createNativeExecutionEngine();
|
||||
let capturedRoutes: unknown;
|
||||
let called = false;
|
||||
|
||||
@@ -2424,7 +2972,7 @@ test('NativeExecutionEngine should dispatch embedding prepared routes through na
|
||||
});
|
||||
|
||||
test('NativeExecutionEngine should dispatch rerank prepared routes through native execution', async t => {
|
||||
const engine = new NativeExecutionEngine();
|
||||
const engine = createNativeExecutionEngine();
|
||||
let capturedRoutes: unknown;
|
||||
let called = false;
|
||||
|
||||
@@ -2507,7 +3055,7 @@ test('NativeExecutionEngine should dispatch rerank prepared routes through nativ
|
||||
});
|
||||
|
||||
test('NativeExecutionEngine should dispatch image plans through prepared native routes', async t => {
|
||||
const engine = new NativeExecutionEngine();
|
||||
const engine = createNativeExecutionEngine();
|
||||
let capturedRoutes: unknown;
|
||||
const original = (serverNativeModule as any).llmImageDispatchPrepared;
|
||||
(serverNativeModule as any).llmImageDispatchPrepared = (
|
||||
@@ -2587,8 +3135,90 @@ test('NativeExecutionEngine should dispatch image plans through prepared native
|
||||
t.snapshot(summarizePreparedDispatchRoutes(capturedRoutes));
|
||||
});
|
||||
|
||||
test('NativeExecutionEngine should record zero-token BYOK image usage without provider usage', async t => {
|
||||
const byok = {
|
||||
recordUsage: Sinon.stub().resolves(),
|
||||
};
|
||||
const engine = new NativeExecutionEngine(byok as never);
|
||||
const providerId = 'byok-aaaaaaaaaaaa-fal-server-key1';
|
||||
|
||||
const original = (serverNativeModule as any).llmImageDispatchPrepared;
|
||||
(serverNativeModule as any).llmImageDispatchPrepared = () => {
|
||||
return JSON.stringify({
|
||||
provider_id: providerId,
|
||||
response: {
|
||||
images: [
|
||||
{
|
||||
url: 'https://cdn.example.com/image.png',
|
||||
media_type: 'image/png',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
||||
t.teardown(() => {
|
||||
(serverNativeModule as any).llmImageDispatchPrepared = original;
|
||||
});
|
||||
|
||||
const request = nativeImageRequest('draw a cat');
|
||||
const imageArtifacts = await collectAsync(
|
||||
engine.executeImageArtifacts({
|
||||
nativeDispatch: {
|
||||
image: {
|
||||
routes: [
|
||||
nativeRoute({
|
||||
providerId,
|
||||
authToken: 'image-key',
|
||||
protocol: 'fal_image',
|
||||
model: 'fal-ai/fast-sdxl',
|
||||
request,
|
||||
}),
|
||||
],
|
||||
prepared: {
|
||||
route: preparedRoute({
|
||||
providerId,
|
||||
authToken: 'image-key',
|
||||
protocol: 'fal_image',
|
||||
model: 'fal-ai/fast-sdxl',
|
||||
}),
|
||||
request,
|
||||
},
|
||||
},
|
||||
},
|
||||
request: {
|
||||
kind: 'image',
|
||||
cond: { modelId: 'fal-ai/fast-sdxl' },
|
||||
messages: singleUserPromptMessages('draw a cat'),
|
||||
options: {
|
||||
workspace: 'workspace-1',
|
||||
user: 'user-1',
|
||||
session: 'session-1',
|
||||
featureKind: 'image',
|
||||
},
|
||||
},
|
||||
routePolicy: { fallbackOrder: [providerId] },
|
||||
runtimePolicy: {},
|
||||
attachmentPolicy: { materializeRemoteAttachments: true },
|
||||
responsePostprocess: { mode: 'image' },
|
||||
hostPersistence: { persistAssistantTurn: true, outputKind: 'image' },
|
||||
hostContext: {},
|
||||
})
|
||||
);
|
||||
|
||||
t.is(imageArtifacts.length, 1);
|
||||
Sinon.assert.calledOnceWithMatch(byok.recordUsage, {
|
||||
workspaceId: 'workspace-1',
|
||||
userId: 'user-1',
|
||||
sessionId: 'session-1',
|
||||
featureKind: 'image',
|
||||
providerId,
|
||||
model: 'fal-ai/fast-sdxl',
|
||||
usage: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('NativeExecutionEngine should reject image plans without native dispatch', async t => {
|
||||
const engine = new NativeExecutionEngine();
|
||||
const engine = createNativeExecutionEngine();
|
||||
|
||||
await t.throwsAsync(
|
||||
collectAsync(
|
||||
|
||||
@@ -534,6 +534,58 @@ test('doc_semantic_search should return empty array when nothing matches', async
|
||||
t.deepEqual(result, []);
|
||||
});
|
||||
|
||||
test('doc_semantic_search should pass BYOK route context into embedding matches', async t => {
|
||||
const ac = {
|
||||
user: () => ({
|
||||
workspace: () => ({
|
||||
can: async () => true,
|
||||
docs: async () => [],
|
||||
}),
|
||||
}),
|
||||
} as unknown as AccessController;
|
||||
|
||||
const models = {
|
||||
workspace: {
|
||||
get: async () => ({ id: 'workspace-1' }),
|
||||
},
|
||||
} as unknown as Models;
|
||||
|
||||
let workspaceRouteContext: unknown;
|
||||
let sessionRouteContext: unknown;
|
||||
const contextService = {
|
||||
matchWorkspaceAll: async (...args: unknown[]) => {
|
||||
workspaceRouteContext = args[7];
|
||||
return [];
|
||||
},
|
||||
getBySessionId: async () => ({
|
||||
matchFiles: async (...args: unknown[]) => {
|
||||
sessionRouteContext = args[5];
|
||||
return [];
|
||||
},
|
||||
}),
|
||||
} as unknown as Parameters<typeof buildDocSearchGetter>[1];
|
||||
|
||||
const semanticTool = createDocSemanticSearchTool(
|
||||
buildDocSearchGetter(ac, contextService, 'session-1', models).bind(null, {
|
||||
user: 'user-1',
|
||||
workspace: 'workspace-1',
|
||||
byokLeaseId: 'lease-1',
|
||||
})
|
||||
);
|
||||
|
||||
const result = await semanticTool.execute?.({ query: 'hello' }, {});
|
||||
|
||||
t.deepEqual(result, []);
|
||||
t.deepEqual(workspaceRouteContext, {
|
||||
userId: 'user-1',
|
||||
byokLeaseId: 'lease-1',
|
||||
});
|
||||
t.deepEqual(sessionRouteContext, {
|
||||
userId: 'user-1',
|
||||
byokLeaseId: 'lease-1',
|
||||
});
|
||||
});
|
||||
|
||||
test('blob_read should return explicit error when attachment context is missing', async t => {
|
||||
const ac = {
|
||||
user: () => ({
|
||||
|
||||
@@ -215,7 +215,7 @@ test('settleTask checks copilot quota before unlocking ready task', async t => {
|
||||
status: 'settled',
|
||||
protectedResult: payload,
|
||||
});
|
||||
const checkQuota = Sinon.stub().rejects(new Error('quota exceeded'));
|
||||
const assertQuotaOrByok = Sinon.stub().rejects(new Error('quota exceeded'));
|
||||
const service = new CopilotTranscriptionService(
|
||||
{
|
||||
copilotTranscriptTask: {
|
||||
@@ -232,14 +232,18 @@ test('settleTask checks copilot quota before unlocking ready task', async t => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ checkQuota } as never
|
||||
{ assertQuotaOrByok } as never
|
||||
);
|
||||
|
||||
await t.throwsAsync(
|
||||
() => service.settleTask('user-1', 'workspace-1', 'task-1'),
|
||||
{ message: /quota exceeded/ }
|
||||
);
|
||||
Sinon.assert.calledOnceWithExactly(checkQuota, 'user-1');
|
||||
Sinon.assert.calledOnceWithMatch(assertQuotaOrByok, {
|
||||
userId: 'user-1',
|
||||
workspaceId: 'workspace-1',
|
||||
featureKind: 'transcript',
|
||||
});
|
||||
Sinon.assert.notCalled(settle);
|
||||
});
|
||||
|
||||
@@ -341,6 +345,48 @@ test('retryTask reuses failed task and queues a new action attempt', async t =>
|
||||
Sinon.assert.calledOnceWithExactly(markRunning, 'task-1');
|
||||
});
|
||||
|
||||
test('retryTask prechecks quota or BYOK before queueing provider work', async t => {
|
||||
const add = Sinon.stub().resolves(undefined);
|
||||
const markRunning = Sinon.stub().resolves({ id: 'task-1' });
|
||||
const assertQuotaOrByok = Sinon.stub().rejects(new Error('quota exceeded'));
|
||||
const payload = TranscriptPayloadSchema.parse({
|
||||
normalizedTranscript: '00:00:05 A: Kickoff',
|
||||
});
|
||||
const service = new CopilotTranscriptionService(
|
||||
{
|
||||
copilotTranscriptTask: {
|
||||
getWithUser: Sinon.stub().resolves({
|
||||
id: 'task-1',
|
||||
status: 'failed',
|
||||
strategy: 'gemini',
|
||||
protectedResult: payload,
|
||||
}),
|
||||
markRunning,
|
||||
},
|
||||
} as never,
|
||||
{ add } as never,
|
||||
{} as never,
|
||||
{
|
||||
resolveTranscriptionModel: Sinon.stub().resolves('gemini-2.5-flash'),
|
||||
} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ assertQuotaOrByok } as never
|
||||
);
|
||||
|
||||
await t.throwsAsync(
|
||||
() => service.retryTask('user-1', 'workspace-1', 'task-1'),
|
||||
{ message: /quota exceeded/ }
|
||||
);
|
||||
Sinon.assert.calledOnceWithMatch(assertQuotaOrByok, {
|
||||
userId: 'user-1',
|
||||
workspaceId: 'workspace-1',
|
||||
featureKind: 'transcript',
|
||||
});
|
||||
Sinon.assert.notCalled(add);
|
||||
Sinon.assert.notCalled(markRunning);
|
||||
});
|
||||
|
||||
for (const status of ['ready', 'settled']) {
|
||||
test(`submitTask allows a new task for the same blob after ${status} task`, async t => {
|
||||
const createdTasks: unknown[] = [];
|
||||
@@ -390,6 +436,37 @@ for (const status of ['ready', 'settled']) {
|
||||
});
|
||||
}
|
||||
|
||||
test('submitTask prechecks quota or BYOK before persisting uploads', async t => {
|
||||
const assertQuotaOrByok = Sinon.stub().rejects(new Error('quota exceeded'));
|
||||
const resolveTranscriptionModel = Sinon.stub().resolves('gemini-2.5-flash');
|
||||
const service = new CopilotTranscriptionService(
|
||||
{
|
||||
copilotTranscriptTask: {
|
||||
getWithUser: Sinon.stub().resolves(null),
|
||||
},
|
||||
} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{
|
||||
resolveTranscriptionModel,
|
||||
} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ assertQuotaOrByok } as never
|
||||
);
|
||||
|
||||
await t.throwsAsync(
|
||||
() => service.submitTask('user-1', 'workspace-1', 'blob-1', []),
|
||||
{ message: /quota exceeded/ }
|
||||
);
|
||||
Sinon.assert.calledOnceWithMatch(assertQuotaOrByok, {
|
||||
userId: 'user-1',
|
||||
workspaceId: 'workspace-1',
|
||||
featureKind: 'transcript',
|
||||
});
|
||||
Sinon.assert.notCalled(resolveTranscriptionModel);
|
||||
});
|
||||
|
||||
test('submitTask rejects unavailable transcript strategy', async t => {
|
||||
const service = new CopilotTranscriptionService(
|
||||
{
|
||||
|
||||
@@ -1145,6 +1145,110 @@ test('should count action runs without double-counting legacy action sessions',
|
||||
t.truthy(legacyAction.sessionId);
|
||||
});
|
||||
|
||||
test('should exclude BYOK provider usage from copilot quota cost', async t => {
|
||||
const { copilotSession, db, models } = t.context;
|
||||
await createTestPrompts(copilotSession, db);
|
||||
|
||||
const regular = await createTestSession(t);
|
||||
const firstMessage = await copilotSession.appendMessage({
|
||||
sessionId: regular.sessionId,
|
||||
userId: user.id,
|
||||
prompt: { model: 'gpt-5-mini' },
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'regular message',
|
||||
createdAt: new Date(),
|
||||
},
|
||||
});
|
||||
const secondMessage = await copilotSession.appendMessage({
|
||||
sessionId: regular.sessionId,
|
||||
userId: user.id,
|
||||
prompt: { model: 'gpt-5-mini' },
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'second BYOK message',
|
||||
createdAt: new Date(),
|
||||
},
|
||||
});
|
||||
await copilotSession.appendMessage({
|
||||
sessionId: regular.sessionId,
|
||||
userId: user.id,
|
||||
prompt: { model: 'gpt-5-mini' },
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'quota-backed message',
|
||||
createdAt: new Date(),
|
||||
},
|
||||
});
|
||||
const failedRun = await models.copilotActionRun.create({
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
actionId: 'mindmap.generate',
|
||||
actionVersion: 'v1',
|
||||
});
|
||||
await models.copilotActionRun.complete(failedRun.id, {
|
||||
status: 'failed',
|
||||
errorCode: 'test_failed',
|
||||
});
|
||||
const pendingTranscriptTask = await models.copilotTranscriptTask.create({
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
blobId: 'pending-audio',
|
||||
strategy: 'gemini',
|
||||
recipeId: 'transcript.audio.gemini',
|
||||
recipeVersion: 'v1',
|
||||
});
|
||||
await models.copilotUsage.create({
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
provider: 'openai',
|
||||
providerSource: 'byok_server',
|
||||
featureKind: 'chat',
|
||||
billingUnitId: firstMessage.id,
|
||||
});
|
||||
await models.copilotUsage.create({
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
provider: 'openai',
|
||||
providerSource: 'byok_server',
|
||||
featureKind: 'chat',
|
||||
billingUnitId: firstMessage.id,
|
||||
});
|
||||
await models.copilotUsage.create({
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
provider: 'fal',
|
||||
providerSource: 'byok_server',
|
||||
featureKind: 'image',
|
||||
billingUnitId: secondMessage.id,
|
||||
});
|
||||
await models.copilotUsage.create({
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
provider: 'fal',
|
||||
providerSource: 'byok_server',
|
||||
featureKind: 'image',
|
||||
});
|
||||
await models.copilotUsage.create({
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
provider: 'openai',
|
||||
providerSource: 'byok_server',
|
||||
featureKind: 'action',
|
||||
billingUnitId: failedRun.id,
|
||||
});
|
||||
await models.copilotUsage.create({
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
provider: 'gemini',
|
||||
providerSource: 'byok_server',
|
||||
featureKind: 'transcript',
|
||||
billingUnitId: pendingTranscriptTask.id,
|
||||
});
|
||||
|
||||
t.is(await copilotSession.countUserMessages(user.id), 1);
|
||||
});
|
||||
|
||||
test('should get sessions for title generation correctly', async t => {
|
||||
const { copilotSession, db } = t.context;
|
||||
await createTestPrompts(copilotSession, db);
|
||||
|
||||
Reference in New Issue
Block a user