diff --git a/.env.example b/.env.example index 322c01d..9d321a7 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/app/api/0pt/documents/structure/route.ts b/app/api/0pt/documents/structure/route.ts index 6bda1ed..e823d41 100644 --- a/app/api/0pt/documents/structure/route.ts +++ b/app/api/0pt/documents/structure/route.ts @@ -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: { diff --git a/docs/guides/document-workbench.md b/docs/guides/document-workbench.md index ad0fe01..a5c7469 100644 --- a/docs/guides/document-workbench.md +++ b/docs/guides/document-workbench.md @@ -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 diff --git a/src/document-workbench/inf0-gateway.ts b/src/document-workbench/inf0-gateway.ts new file mode 100644 index 0000000..a0479ff --- /dev/null +++ b/src/document-workbench/inf0-gateway.ts @@ -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[0]): ReturnType; +} + +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; +} diff --git a/src/document-workbench/litellm-gateway-client.ts b/src/document-workbench/litellm-gateway-client.ts new file mode 100644 index 0000000..0adbed4 --- /dev/null +++ b/src/document-workbench/litellm-gateway-client.ts @@ -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 { + 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, + }; + } +} diff --git a/src/document-workbench/project-access.ts b/src/document-workbench/project-access.ts index dd598ce..1bd69a8 100644 --- a/src/document-workbench/project-access.ts +++ b/src/document-workbench/project-access.ts @@ -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; +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; + 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)); } diff --git a/tests/document-workbench/litellm-gateway-client.test.ts b/tests/document-workbench/litellm-gateway-client.test.ts new file mode 100644 index 0000000..d6e1477 --- /dev/null +++ b/tests/document-workbench/litellm-gateway-client.test.ts @@ -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(); + }); +}); diff --git a/tests/document-workbench/project-access.test.ts b/tests/document-workbench/project-access.test.ts new file mode 100644 index 0000000..6396bd8 --- /dev/null +++ b/tests/document-workbench/project-access.test.ts @@ -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); + }); +});