feat(workbench): live LiteLLM gateway adapter + explicit project grants
CI (SIAX Cloud) / integration (push) Skipped
CI (SIAX Cloud) / sonar (push) Skipped
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 6s
masterplan-lock / check (push) Successful in 6s
CI (SIAX Cloud) / security (push) Successful in 14s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 19s
CI (SIAX Cloud) / security (pull_request) Successful in 21s
Security Scan (SIAX Cloud) / security (push) Successful in 25s
CI (SIAX Cloud) / integration (pull_request) Successful in 29s
CI (SIAX Cloud) / quality (pull_request) Successful in 51s
CI (SIAX Cloud) / quality (push) Successful in 1m29s
CI (SIAX Cloud) / integration (push) Skipped
CI (SIAX Cloud) / sonar (push) Skipped
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 6s
masterplan-lock / check (push) Successful in 6s
CI (SIAX Cloud) / security (push) Successful in 14s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 19s
CI (SIAX Cloud) / security (pull_request) Successful in 21s
Security Scan (SIAX Cloud) / security (push) Successful in 25s
CI (SIAX Cloud) / integration (pull_request) Successful in 29s
CI (SIAX Cloud) / quality (pull_request) Successful in 51s
CI (SIAX Cloud) / quality (push) Successful in 1m29s
- createInf0Gateway prefers the live OpenAI-compatible LiteLLM gateway and falls back to the native INF0 surface; admission stays local and fail-closed. - Project authorization: OpenFGA when configured, otherwise a validated server-owned OPT_PROJECT_GRANTS list (deny by default). - Tests for the gateway adapter, the factory and grants.
This commit is contained in:
@@ -216,6 +216,11 @@ DOC0_SERVICE_TOKEN=
|
||||
OPT_MAX_UPLOAD_BYTES=26214400
|
||||
|
||||
# INF0 AI gateway (model calls). No provider key is ever placed in this repo.
|
||||
# Live path today: the LiteLLM gateway (OpenAI-compatible). The native
|
||||
# /v1/inf0/* surface is contract-only; INF0_URL is used as a fallback.
|
||||
INF0_GATEWAY_URL=https://ai-gateway.siax.io/v1
|
||||
INF0_GATEWAY_LITELLM_KEY=
|
||||
INF0_GATEWAY_PROVIDER_KIND=external
|
||||
INF0_URL=https://api.inf0.siax.io
|
||||
INF0_API_TOKEN=
|
||||
|
||||
@@ -236,6 +241,11 @@ OPENFGA_STORE_ID=
|
||||
OPENFGA_MODEL_ID=
|
||||
OPENFGA_AUTH_TOKEN=
|
||||
|
||||
# Transitional, server-owned project grants used only when OpenFGA is not
|
||||
# configured. JSON array; actorId "*" means every verified actor in the tenant.
|
||||
# OPT_PROJECT_GRANTS=[{"tenantId":"379976258701823235","projectId":"prj_demo","actorId":"*"}]
|
||||
OPT_PROJECT_GRANTS=
|
||||
|
||||
# Model policy for the document workbench. Without this, structuring answers
|
||||
# MODEL_POLICY_NOT_CONFIGURED.
|
||||
OPT_ALLOWED_MODELS=
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse, type NextRequest } from 'next/server';
|
||||
import { Doc0SourceClient, Doc0SourceUnavailableError } from '@/src/document-workbench/doc0-source-client';
|
||||
import { Inf0GatewayClient, Inf0OutcomeUnknownError } from '@/src/document-workbench/inf0-gateway-client';
|
||||
import { Inf0OutcomeUnknownError } from '@/src/document-workbench/inf0-gateway-client';
|
||||
import { createInf0Gateway } from '@/src/document-workbench/inf0-gateway';
|
||||
import { PostgresCreditLedger } from '@/src/document-workbench/credit-ledger';
|
||||
import { creditsForUsage, readCreditPriceBook } from '@/src/document-workbench/price-book';
|
||||
import { structureDocument } from '@/src/document-workbench/structure-document';
|
||||
@@ -106,7 +107,8 @@ export async function POST(request: NextRequest) {
|
||||
const { tenantId, actorId } = verified.scope;
|
||||
|
||||
const runtimeConfig = readDocumentWorkbenchRuntime();
|
||||
if (!runtimeConfig.doc0 || !runtimeConfig.inf0) {
|
||||
const gateway = createInf0Gateway();
|
||||
if (!runtimeConfig.doc0 || !gateway) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'WORKBENCH_NOT_CONFIGURED',
|
||||
@@ -279,10 +281,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
try {
|
||||
const result = await structureDocument({
|
||||
gateway: new Inf0GatewayClient({
|
||||
baseUrl: runtimeConfig.inf0.baseUrl,
|
||||
serviceToken: runtimeConfig.inf0.serviceToken,
|
||||
}),
|
||||
gateway,
|
||||
request: {
|
||||
context,
|
||||
admission: {
|
||||
|
||||
@@ -22,7 +22,9 @@ UI: `/portal/dokument` (upload, structure, review, download) and `/portal/kredit
|
||||
1. **Tenant and actor come only from middleware's verified headers.** A customer
|
||||
route never trusts `tenant_id`/`actor_id` from the body.
|
||||
2. **Project access is checked by OpenFGA** (`@siax/access`), the same primitive
|
||||
DOC0 uses. Unconfigured or unavailable means *denied*, never allowed.
|
||||
DOC0 uses. Until the central relationship store is deployed, an explicit
|
||||
server-owned grant list (`OPT_PROJECT_GRANTS`) can be used instead.
|
||||
Unconfigured or unavailable means *denied*, never allowed.
|
||||
3. **The model never sees unverified text.** `Doc0SourceClient` verifies
|
||||
version, tenant, checksum, segment hashes and that the index was not
|
||||
truncated before anything is sent to INF0.
|
||||
@@ -45,6 +47,14 @@ UI: `/portal/dokument` (upload, structure, review, download) and `/portal/kredit
|
||||
modules have no Next.js imports and can be lifted into a published package
|
||||
unchanged.
|
||||
|
||||
## Which AI path is used
|
||||
|
||||
`createInf0Gateway()` prefers the **live LiteLLM gateway**
|
||||
(`INF0_GATEWAY_LITELLM_KEY`, `https://ai-gateway.siax.io/v1`, OpenAI-compatible)
|
||||
and falls back to the native `INF0_URL` surface. Admission — tenant/project
|
||||
scope, model allow-list, data class, external-transfer policy and token limits —
|
||||
runs locally in both paths before the request leaves the process.
|
||||
|
||||
## What is still deliberately not claimed
|
||||
|
||||
- The output is `mechanically_validated`, not proven complete or semantically
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Resolve the live INF0 inference path.
|
||||
*
|
||||
* Preference order:
|
||||
* 1. INF0_GATEWAY_LITELLM_KEY + INF0_GATEWAY_URL (live LiteLLM, used today)
|
||||
* 2. INF0_URL + INF0_API_TOKEN (native `/v1/inf0/*` surface, contract-only)
|
||||
*
|
||||
* Returns `undefined` when neither is configured, so the caller can fail
|
||||
* closed with an explicit "not configured" response.
|
||||
*/
|
||||
import { Inf0GatewayClient } from './inf0-gateway-client';
|
||||
import { LiteLlmGatewayClient } from './litellm-gateway-client';
|
||||
import { isTrustedHttpsUrl } from './runtime-config';
|
||||
|
||||
export interface Inf0Gateway {
|
||||
complete(input: Parameters<Inf0GatewayClient['complete']>[0]): ReturnType<Inf0GatewayClient['complete']>;
|
||||
}
|
||||
|
||||
export function createInf0Gateway(env: NodeJS.ProcessEnv = process.env): Inf0Gateway | undefined {
|
||||
const litellmKey = env.INF0_GATEWAY_LITELLM_KEY?.trim();
|
||||
const litellmUrl = env.INF0_GATEWAY_URL?.trim() || 'https://ai-gateway.siax.io/v1';
|
||||
if (litellmKey && isTrustedHttpsUrl(litellmUrl)) {
|
||||
try {
|
||||
return new LiteLlmGatewayClient({
|
||||
baseUrl: litellmUrl,
|
||||
apiKey: litellmKey,
|
||||
providerKind: env.INF0_GATEWAY_PROVIDER_KIND === 'local' ? 'local' : 'external',
|
||||
});
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
const baseUrl = env.INF0_URL ?? env.INF0_API_URL;
|
||||
const token = env.INF0_API_TOKEN ?? env.INF0_SERVICE_TOKEN;
|
||||
if (isTrustedHttpsUrl(baseUrl) && token?.trim()) {
|
||||
try {
|
||||
return new Inf0GatewayClient({ baseUrl, serviceToken: token.trim() });
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
admitModelExecution,
|
||||
type ModelAdmissionRequest,
|
||||
type ModelBudgetReservation,
|
||||
type ModelExecutionPolicy,
|
||||
} from './model-admission';
|
||||
import { Inf0OutcomeUnknownError, type Inf0GatewayReceipt } from './inf0-gateway-client';
|
||||
import type { SiaxRequestContext } from '../platform/siax-context';
|
||||
|
||||
type Message = { role: 'system' | 'user' | 'assistant'; content: string };
|
||||
|
||||
export interface GatewayCompleteInput {
|
||||
context: SiaxRequestContext;
|
||||
admission: ModelAdmissionRequest;
|
||||
policy: ModelExecutionPolicy;
|
||||
reservation: ModelBudgetReservation | undefined;
|
||||
inputTokens: number;
|
||||
messages: readonly Message[];
|
||||
}
|
||||
|
||||
/**
|
||||
* INF0's live inference anchor today is the LiteLLM gateway (OpenAI-compatible,
|
||||
* `ai-gateway.siax.io/v1`). The `/v1/inf0/*` SIAX surface is contract-only, so
|
||||
* this adapter calls the live route while keeping the exact same admission
|
||||
* semantics as the future native client: scope, allow-list, data class,
|
||||
* external-transfer policy and token limits are all enforced locally before
|
||||
* the request leaves the process.
|
||||
*
|
||||
* No provider key is placed in this repo: the caller passes a per-tenant
|
||||
* LiteLLM virtual key resolved from the environment at runtime.
|
||||
*/
|
||||
export class LiteLlmGatewayClient {
|
||||
private readonly baseUrl: string;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
|
||||
constructor(
|
||||
private readonly options: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
providerKind?: 'local' | 'external';
|
||||
fetchImpl?: typeof fetch;
|
||||
timeoutMs?: number;
|
||||
},
|
||||
) {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(options.baseUrl);
|
||||
} catch {
|
||||
throw new Error('Invalid INF0 gateway URL');
|
||||
}
|
||||
if (
|
||||
url.protocol !== 'https:' ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.search ||
|
||||
url.hash ||
|
||||
!options.apiKey.trim()
|
||||
) {
|
||||
throw new Error('A trusted HTTPS INF0 gateway and key are required');
|
||||
}
|
||||
this.baseUrl = url.toString().replace(/\/+$/, '');
|
||||
this.fetchImpl = options.fetchImpl ?? fetch;
|
||||
this.options = { ...options };
|
||||
}
|
||||
|
||||
async complete(input: GatewayCompleteInput): Promise<Inf0GatewayReceipt> {
|
||||
const { context, admission, policy, reservation, inputTokens, messages } = structuredClone(input);
|
||||
const decision = admitModelExecution(admission, policy, reservation);
|
||||
if (!decision.allowed) {
|
||||
throw new Error(`Model execution denied: ${decision.reason}`);
|
||||
}
|
||||
if (
|
||||
context.tenantId !== admission.tenantId ||
|
||||
context.projectId !== admission.projectId ||
|
||||
!context.actorId?.trim() ||
|
||||
!context.correlationId?.trim() ||
|
||||
!Number.isSafeInteger(inputTokens) ||
|
||||
inputTokens <= 0 ||
|
||||
inputTokens > admission.maxInputTokens ||
|
||||
!Array.isArray(messages) ||
|
||||
messages.length === 0
|
||||
) {
|
||||
throw new Error('Invalid scoped model request');
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.fetchImpl(`${this.baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(this.options.timeoutMs ?? 120_000),
|
||||
headers: {
|
||||
authorization: `Bearer ${this.options.apiKey}`,
|
||||
'content-type': 'application/json',
|
||||
'x-tenant-id': context.tenantId,
|
||||
'x-siax-project-id': context.projectId ?? '',
|
||||
'x-actor-id': context.actorId,
|
||||
'x-siax-correlation-id': context.correlationId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: admission.modelId,
|
||||
messages,
|
||||
max_tokens: admission.maxOutputTokens,
|
||||
temperature: 0,
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
throw new Inf0OutcomeUnknownError();
|
||||
}
|
||||
if (!response.ok) throw new Inf0OutcomeUnknownError();
|
||||
|
||||
let decoded: unknown;
|
||||
try {
|
||||
decoded = await response.json();
|
||||
} catch {
|
||||
throw new Inf0OutcomeUnknownError();
|
||||
}
|
||||
const result = decoded as {
|
||||
model?: unknown;
|
||||
choices?: Array<{ message?: { content?: unknown }; finish_reason?: unknown }>;
|
||||
usage?: { prompt_tokens?: unknown; completion_tokens?: unknown; total_tokens?: unknown };
|
||||
} | null;
|
||||
const content = result?.choices?.[0]?.message?.content;
|
||||
const promptTokens = result?.usage?.prompt_tokens;
|
||||
const completionTokens = result?.usage?.completion_tokens;
|
||||
const totalTokens = result?.usage?.total_tokens;
|
||||
if (
|
||||
!result ||
|
||||
typeof content !== 'string' ||
|
||||
typeof result.model !== 'string' ||
|
||||
!result.model.trim() ||
|
||||
!Number.isSafeInteger(promptTokens) ||
|
||||
!Number.isSafeInteger(completionTokens) ||
|
||||
!Number.isSafeInteger(totalTokens) ||
|
||||
(promptTokens as number) < 0 ||
|
||||
(promptTokens as number) > admission.maxInputTokens ||
|
||||
(completionTokens as number) > admission.maxOutputTokens ||
|
||||
(promptTokens as number) + (completionTokens as number) !== totalTokens
|
||||
) {
|
||||
throw new Inf0OutcomeUnknownError();
|
||||
}
|
||||
|
||||
const providerKind = this.options.providerKind ?? 'external';
|
||||
if (policy.destination === 'internal' && providerKind !== 'local') {
|
||||
throw new Inf0OutcomeUnknownError();
|
||||
}
|
||||
const finish = result.choices?.[0]?.finish_reason;
|
||||
|
||||
return {
|
||||
status:
|
||||
finish === 'stop' && (completionTokens as number) > 0 && content.trim()
|
||||
? 'model_stopped'
|
||||
: 'needs_review',
|
||||
content,
|
||||
modelExecuted: result.model,
|
||||
providerKind,
|
||||
promptTokens: promptTokens as number,
|
||||
completionTokens: completionTokens as number,
|
||||
totalTokens: totalTokens as number,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,22 @@
|
||||
/**
|
||||
* 0pt project authorization for the document workbench.
|
||||
*
|
||||
* Reuses the universal `@siax/access` OpenFGA relationship primitive (the same
|
||||
* one DOC0 proved) instead of a per-repo copy. FAIL CLOSED: when OpenFGA is not
|
||||
* configured, or the relationship source is unavailable, the answer is "no".
|
||||
* Two sources, in order:
|
||||
*
|
||||
* 1. OpenFGA via the universal `@siax/access` primitive (the same one DOC0
|
||||
* uses) when `OPENFGA_API_URL/STORE_ID/MODEL_ID/AUTH_TOKEN` are set.
|
||||
* 2. An explicit, server-owned grant list (`OPT_PROJECT_GRANTS`), a
|
||||
* transitional source for environments where the central relationship
|
||||
* store is not deployed yet.
|
||||
*
|
||||
* FAIL CLOSED: when neither source is configured, or the source is
|
||||
* unavailable, the answer is "no". A grant is never derived from a request
|
||||
* header or body.
|
||||
*
|
||||
* `OPT_PROJECT_GRANTS` shape (JSON array):
|
||||
* [{"tenantId":"379976258701823235","projectId":"prj_alpha","actorId":"*"}]
|
||||
* `actorId: "*"` grants every verified actor in the tenant; otherwise the exact
|
||||
* actor must match.
|
||||
*/
|
||||
import { OpenFgaRelationships, isProjectAccessAllowed } from '@siax/access';
|
||||
import { readDocumentWorkbenchRuntime } from './runtime-config';
|
||||
@@ -16,6 +29,48 @@ export interface ProjectScope {
|
||||
|
||||
export type ProjectAuthorizer = (scope: ProjectScope) => Promise<boolean>;
|
||||
|
||||
interface ProjectGrant {
|
||||
tenantId: string;
|
||||
projectId: string;
|
||||
actorId: string;
|
||||
}
|
||||
|
||||
const scopeId = /^[\x21-\x7e]{1,256}$/;
|
||||
|
||||
export function parseProjectGrants(raw: string | undefined): ProjectGrant[] | undefined {
|
||||
if (!raw || !raw.trim()) return undefined;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (!Array.isArray(parsed) || parsed.length === 0 || parsed.length > 1000) {
|
||||
return undefined;
|
||||
}
|
||||
const grants: ProjectGrant[] = [];
|
||||
for (const entry of parsed) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return undefined;
|
||||
const grant = entry as Record<string, unknown>;
|
||||
if (
|
||||
typeof grant.tenantId !== 'string' ||
|
||||
!scopeId.test(grant.tenantId) ||
|
||||
typeof grant.projectId !== 'string' ||
|
||||
!scopeId.test(grant.projectId) ||
|
||||
typeof grant.actorId !== 'string' ||
|
||||
(grant.actorId !== '*' && !scopeId.test(grant.actorId))
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
grants.push({
|
||||
tenantId: grant.tenantId,
|
||||
projectId: grant.projectId,
|
||||
actorId: grant.actorId,
|
||||
});
|
||||
}
|
||||
return grants;
|
||||
}
|
||||
|
||||
export function createProjectAuthorizer(env: NodeJS.ProcessEnv = process.env): ProjectAuthorizer {
|
||||
const runtime = readDocumentWorkbenchRuntime(env);
|
||||
let relationships: OpenFgaRelationships | undefined;
|
||||
@@ -26,10 +81,27 @@ export function createProjectAuthorizer(env: NodeJS.ProcessEnv = process.env): P
|
||||
relationships = undefined;
|
||||
}
|
||||
}
|
||||
return async (scope) => isProjectAccessAllowed(scope, { relationships });
|
||||
const grants = parseProjectGrants(env.OPT_PROJECT_GRANTS);
|
||||
|
||||
return async (scope) => {
|
||||
if (!scopeId.test(scope.tenantId) || !scopeId.test(scope.projectId) || !scopeId.test(scope.actorId)) {
|
||||
return false;
|
||||
}
|
||||
if (relationships) {
|
||||
return isProjectAccessAllowed(scope, { relationships });
|
||||
}
|
||||
if (!grants) return false;
|
||||
return grants.some(
|
||||
(grant) =>
|
||||
grant.tenantId === scope.tenantId &&
|
||||
grant.projectId === scope.projectId &&
|
||||
(grant.actorId === '*' || grant.actorId === scope.actorId),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/** True when the server has enough configuration to authorize at all. */
|
||||
export function projectAuthorizationConfigured(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return Boolean(readDocumentWorkbenchRuntime(env).openfga);
|
||||
if (readDocumentWorkbenchRuntime(env).openfga) return true;
|
||||
return Boolean(parseProjectGrants(env.OPT_PROJECT_GRANTS));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { LiteLlmGatewayClient } from '../../src/document-workbench/litellm-gateway-client';
|
||||
import { Inf0OutcomeUnknownError } from '../../src/document-workbench/inf0-gateway-client';
|
||||
import { createInf0Gateway } from '../../src/document-workbench/inf0-gateway';
|
||||
|
||||
const context = {
|
||||
tenantId: 't_0pt',
|
||||
projectId: 'prj_alpha',
|
||||
actorId: 'user_1',
|
||||
capability: '0pt.document.structure.v1',
|
||||
correlationId: 'corr-1',
|
||||
};
|
||||
const admission = {
|
||||
tenantId: 't_0pt',
|
||||
projectId: 'prj_alpha',
|
||||
jobId: 'job-1',
|
||||
modelId: 'inf0-standard',
|
||||
dataClass: 'low',
|
||||
maxInputTokens: 1000,
|
||||
maxOutputTokens: 200,
|
||||
maximumCredits: 5,
|
||||
};
|
||||
const policy = {
|
||||
tenantId: 't_0pt',
|
||||
projectId: 'prj_alpha',
|
||||
allowedModelIds: ['inf0-standard'],
|
||||
allowedDataClasses: ['low'],
|
||||
destination: 'external' as const,
|
||||
externalTransferAllowed: true,
|
||||
maxInputTokens: 1000,
|
||||
maxOutputTokens: 200,
|
||||
};
|
||||
const messages = [
|
||||
{ role: 'system' as const, content: 'system' },
|
||||
{ role: 'user' as const, content: 'data' },
|
||||
];
|
||||
|
||||
function gateway(fetchImpl: typeof fetch, providerKind: 'local' | 'external' = 'external') {
|
||||
return new LiteLlmGatewayClient({
|
||||
baseUrl: 'https://ai-gateway.siax.io/v1',
|
||||
apiKey: 'sk-test',
|
||||
providerKind,
|
||||
fetchImpl,
|
||||
});
|
||||
}
|
||||
|
||||
function ok(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), { status: 200 });
|
||||
}
|
||||
|
||||
describe('LiteLLM INF0 adapter', () => {
|
||||
it('maps an OpenAI-compatible response into the INF0 receipt', async () => {
|
||||
const client = gateway(async () =>
|
||||
ok({
|
||||
model: 'inf0-standard',
|
||||
choices: [{ message: { content: '{"ok":true}' }, finish_reason: 'stop' }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
client.complete({
|
||||
context,
|
||||
admission,
|
||||
policy,
|
||||
reservation: {
|
||||
tenantId: 't_0pt',
|
||||
projectId: 'prj_alpha',
|
||||
jobId: 'job-1',
|
||||
status: 'reserved',
|
||||
credits: 5,
|
||||
},
|
||||
inputTokens: 10,
|
||||
messages,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
status: 'model_stopped',
|
||||
content: '{"ok":true}',
|
||||
modelExecuted: 'inf0-standard',
|
||||
providerKind: 'external',
|
||||
totalTokens: 15,
|
||||
});
|
||||
});
|
||||
|
||||
it('denies a model outside the allow-list before any request', async () => {
|
||||
let called = false;
|
||||
const client = gateway(async () => {
|
||||
called = true;
|
||||
return ok({});
|
||||
});
|
||||
await expect(
|
||||
client.complete({
|
||||
context,
|
||||
admission: { ...admission, modelId: 'inf0-frontier' },
|
||||
policy,
|
||||
reservation: undefined,
|
||||
inputTokens: 10,
|
||||
messages,
|
||||
}),
|
||||
).rejects.toThrow(/denied/i);
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses an external provider when the policy requires internal', async () => {
|
||||
const client = gateway(
|
||||
async () =>
|
||||
ok({
|
||||
model: 'inf0-standard',
|
||||
choices: [{ message: { content: 'x' }, finish_reason: 'stop' }],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
}),
|
||||
'external',
|
||||
);
|
||||
await expect(
|
||||
client.complete({
|
||||
context,
|
||||
admission,
|
||||
policy: { ...policy, destination: 'internal' },
|
||||
reservation: {
|
||||
tenantId: 't_0pt',
|
||||
projectId: 'prj_alpha',
|
||||
jobId: 'job-1',
|
||||
status: 'reserved',
|
||||
credits: 5,
|
||||
},
|
||||
inputTokens: 1,
|
||||
messages,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(Inf0OutcomeUnknownError);
|
||||
});
|
||||
|
||||
it('treats malformed or inconsistent usage as an unknown outcome', async () => {
|
||||
const client = gateway(async () =>
|
||||
ok({
|
||||
model: 'inf0-standard',
|
||||
choices: [{ message: { content: 'x' }, finish_reason: 'stop' }],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 99 },
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
client.complete({
|
||||
context,
|
||||
admission,
|
||||
policy,
|
||||
reservation: {
|
||||
tenantId: 't_0pt',
|
||||
projectId: 'prj_alpha',
|
||||
jobId: 'job-1',
|
||||
status: 'reserved',
|
||||
credits: 5,
|
||||
},
|
||||
inputTokens: 1,
|
||||
messages,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(Inf0OutcomeUnknownError);
|
||||
});
|
||||
|
||||
it('prefers the live LiteLLM key and falls back to the native surface', () => {
|
||||
expect(createInf0Gateway({} as unknown as NodeJS.ProcessEnv)).toBeUndefined();
|
||||
expect(
|
||||
createInf0Gateway({
|
||||
INF0_GATEWAY_LITELLM_KEY: 'sk-test',
|
||||
} as unknown as NodeJS.ProcessEnv),
|
||||
).toBeInstanceOf(LiteLlmGatewayClient);
|
||||
expect(
|
||||
createInf0Gateway({
|
||||
INF0_URL: 'https://api.inf0.siax.io',
|
||||
INF0_API_TOKEN: 'token',
|
||||
} as unknown as NodeJS.ProcessEnv),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
createProjectAuthorizer,
|
||||
parseProjectGrants,
|
||||
projectAuthorizationConfigured,
|
||||
} from '../../src/document-workbench/project-access';
|
||||
|
||||
const scope = { tenantId: 't_0pt', projectId: 'prj_alpha', actorId: 'user_1' };
|
||||
|
||||
describe('project authorization', () => {
|
||||
it('fails closed when neither OpenFGA nor explicit grants are configured', async () => {
|
||||
const env = {} as unknown as NodeJS.ProcessEnv;
|
||||
expect(projectAuthorizationConfigured(env)).toBe(false);
|
||||
await expect(createProjectAuthorizer(env)(scope)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('honours an exact actor grant and rejects a different actor or project', async () => {
|
||||
const env = {
|
||||
OPT_PROJECT_GRANTS: JSON.stringify([{ tenantId: 't_0pt', projectId: 'prj_alpha', actorId: 'user_1' }]),
|
||||
} as unknown as NodeJS.ProcessEnv;
|
||||
const authorize = createProjectAuthorizer(env);
|
||||
await expect(authorize(scope)).resolves.toBe(true);
|
||||
await expect(authorize({ ...scope, actorId: 'user_2' })).resolves.toBe(false);
|
||||
await expect(authorize({ ...scope, projectId: 'prj_beta' })).resolves.toBe(false);
|
||||
await expect(authorize({ ...scope, tenantId: 't_other' })).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('supports a tenant-wide wildcard actor', async () => {
|
||||
const env = {
|
||||
OPT_PROJECT_GRANTS: JSON.stringify([{ tenantId: 't_0pt', projectId: 'prj_alpha', actorId: '*' }]),
|
||||
} as unknown as NodeJS.ProcessEnv;
|
||||
await expect(createProjectAuthorizer(env)({ ...scope, actorId: 'anyone' })).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('treats an invalid grant document as unconfigured', () => {
|
||||
expect(parseProjectGrants('{not json')).toBeUndefined();
|
||||
expect(parseProjectGrants(JSON.stringify([]))).toBeUndefined();
|
||||
expect(parseProjectGrants(JSON.stringify([{ tenantId: 't', projectId: 'p' }]))).toBeUndefined();
|
||||
expect(
|
||||
projectAuthorizationConfigured({
|
||||
OPT_PROJECT_GRANTS: 'not-json',
|
||||
} as unknown as NodeJS.ProcessEnv),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user