diff --git a/apps/hub/src/app.ts b/apps/hub/src/app.ts index 71ebfcd7..689919ee 100644 --- a/apps/hub/src/app.ts +++ b/apps/hub/src/app.ts @@ -561,6 +561,7 @@ export async function createHubRuntime( credentialCipher, config, installRootId, + async (request) => app.fetch(request), ), ); const modelLogins = mountModelLogins(app, { diff --git a/apps/hub/src/artifacts/service.ts b/apps/hub/src/artifacts/service.ts index 517c44d7..a44ba985 100644 --- a/apps/hub/src/artifacts/service.ts +++ b/apps/hub/src/artifacts/service.ts @@ -135,6 +135,7 @@ import { } from "../config/preferences.ts"; import { awaitingApproval } from "../projects/deploy-tools.ts"; import { frozenModelChoice, workflowBindings } from "../config/models.ts"; +import { modelDirectory } from "../settings/custom-providers.ts"; import { materialsNote } from "../config/materials.ts"; import { servingOffering } from "../workflows/install-for-tenant.ts"; import { @@ -1003,13 +1004,15 @@ export function createArtifactService( ): Promise { await requireProject(actor, projectId, "read"); const { models } = await loadSettings(rawDB, actor.tenantId, projectId); - const choice = frozenModelChoice(models.value); + const directory = await modelDirectory(rawDB, actor.tenantId); + const choice = frozenModelChoice(directory, models.value); const offering = choice && (await servingOffering( rawDB, + directory, actor.tenantId, - workflowBindings(choice).coordinator, + workflowBindings(directory, choice).coordinator, )); return acceptedMaterials(offering?.capabilities ?? []); }, diff --git a/apps/hub/src/availability.ts b/apps/hub/src/availability.ts index 51554399..d0afc8ef 100644 --- a/apps/hub/src/availability.ts +++ b/apps/hub/src/availability.ts @@ -29,7 +29,11 @@ const log = getLogger(["corbits-builder", "availability"]); // These are wire-protocol adapters, not a provider/model registry. Identity, // endpoints, shadowing and credential authority all come from the native catalog. -function probeRequest(plugin: string, baseURL: string, secret: string) { +function probeRequest( + plugin: string, + baseURL: string, + secret: string | undefined, +) { const url = new URL(baseURL); if ( !["http:", "https:"].includes(url.protocol) || @@ -45,16 +49,17 @@ function probeRequest(plugin: string, baseURL: string, secret: string) { case "openai-responses": case "openai-compatible": url.pathname = `${url.pathname.replace(/\/$/, "")}/models`; - headers.set("authorization", `Bearer ${secret}`); + if (secret !== undefined) + headers.set("authorization", `Bearer ${secret}`); break; case "anthropic": url.pathname = `${url.pathname.replace(/\/$/, "")}/v1/models`; - headers.set("x-api-key", secret); + if (secret !== undefined) headers.set("x-api-key", secret); headers.set("anthropic-version", "2023-06-01"); break; case "google-genai": url.pathname = `${url.pathname.replace(/\/$/, "")}/v1beta/models`; - headers.set("x-goog-api-key", secret); + if (secret !== undefined) headers.set("x-goog-api-key", secret); break; default: return null; @@ -139,11 +144,14 @@ export function createAvailabilityProbe( }; } -/** One model-list call with this key; `origin`, when given, is the only origin the key may reach. */ +/** + * One model-list call with this key, or with none for a keyless server; `origin`, when given, is + * the only origin the key may reach. + */ export async function probeKey( plugin: string, baseURL: string, - secret: string, + secret: string | undefined, origin?: string, ): Promise { let request; diff --git a/apps/hub/src/config/models.test.ts b/apps/hub/src/config/models.test.ts index cfefa52b..46cd88ee 100644 --- a/apps/hub/src/config/models.test.ts +++ b/apps/hub/src/config/models.test.ts @@ -1,24 +1,34 @@ import { expect, test } from "bun:test"; -import { resolveModelChoice } from "./models.ts"; +import { MODEL_PROVIDERS, resolveModelChoice } from "./models.ts"; test("a project runs on the only connected provider's profile, a chosen one, or none", () => { - expect(resolveModelChoice(null, ["anthropic"])).toMatchObject({ + expect( + resolveModelChoice(MODEL_PROVIDERS, null, ["anthropic"]), + ).toMatchObject({ choice: { profile: "anthropic_default" }, }); - expect(resolveModelChoice(null, ["anthropic", "openai"])).toEqual({ + expect( + resolveModelChoice(MODEL_PROVIDERS, null, ["anthropic", "openai"]), + ).toEqual({ refused: "model_profile_required", }); - expect(resolveModelChoice(null, [])).toEqual({ + expect(resolveModelChoice(MODEL_PROVIDERS, null, [])).toEqual({ refused: "model_provider_missing", }); expect( - resolveModelChoice({ profile: "openai_default" }, ["anthropic", "openai"]), + resolveModelChoice(MODEL_PROVIDERS, { profile: "openai_default" }, [ + "anthropic", + "openai", + ]), ).toMatchObject({ choice: { profile: "openai_default" }, }); // A stored choice this server no longer offers is no choice. expect( - resolveModelChoice({ profile: "gone_default" }, ["anthropic", "openai"]), + resolveModelChoice(MODEL_PROVIDERS, { profile: "gone_default" }, [ + "anthropic", + "openai", + ]), ).toEqual({ refused: "model_profile_required", }); diff --git a/apps/hub/src/config/models.ts b/apps/hub/src/config/models.ts index 2da7ece0..56db6473 100644 --- a/apps/hub/src/config/models.ts +++ b/apps/hub/src/config/models.ts @@ -1,4 +1,6 @@ import type { + CustomApi, + CustomModel, CustomProfile, Effort, ModelChoice, @@ -8,38 +10,60 @@ import type { Tiers, } from "@corbits-builder/schemas/models"; import type { WorkflowRole } from "@corbits-builder/schemas/installed-workflows"; -import { modelLimits, type AgentRole } from "../inference/model-limits.ts"; +import { + customModelLimits, + modelLimits, + type AgentRole, + type ModelLimits, +} from "../inference/model-limits.ts"; -type ProviderEntry = Readonly<{ +type CommonEntry = { // What a role binding names. Kept apart from the plugin so two entries can share a wire protocol. id: string; // The native catalog plugin that speaks this provider's wire protocol. plugin: string; - // How a person connects it: an API key they paste, or a subscription login run by the hub. - kind: "api_key" | "login"; label: string; hint: string; - needsBaseUrl: boolean; baseURL: string; - // Each model needs a limits record in inference/model-limits.ts before a role can run on it. - models: readonly Readonly<{ id: string; label: string }>[]; // The adapter quirks every offering of this provider carries. quirks?: Readonly>; // The catalog capabilities every offering of this provider declares, for what its models take as input. capabilities?: readonly string[]; -}>; +}; + +type ShippedEntry = Readonly< + CommonEntry & { + // How a person connects it: an API key they paste, or a subscription login run by the hub. + kind: "api_key" | "login"; + // Each model needs a limits record in inference/model-limits.ts before a role can run on it. + models: readonly Readonly<{ id: string; label: string }>[]; + } +>; + +// A person's own endpoint: its id is Builder's, so it outlives the catalog connection it currently +// runs on, and its models carry their own limits. +type CustomEntry = Readonly< + CommonEntry & { + kind: "custom"; + // The catalog model provider it runs on. + connection: string; + keyless: boolean; + models: readonly CustomModel[]; + } +>; + +export type ProviderEntry = ShippedEntry | CustomEntry; // The model providers a person can connect. Adding a provider is one entry here with at least one // model; adding a model is one entry in its `models` and one limits record. A provider no role can // run on is not listed, so connecting it is never offered. -export const MODEL_PROVIDERS: readonly ProviderEntry[] = [ +export const MODEL_PROVIDERS: readonly ShippedEntry[] = [ { id: "openai", plugin: "openai-responses", kind: "api_key", label: "OpenAI", hint: "An OpenAI API key.", - needsBaseUrl: false, baseURL: "https://api.openai.com/v1", models: [ { id: "gpt-6-astra", label: "GPT-6 Astra" }, @@ -57,7 +81,6 @@ export const MODEL_PROVIDERS: readonly ProviderEntry[] = [ kind: "api_key", label: "Anthropic", hint: "An Anthropic API key.", - needsBaseUrl: false, baseURL: "https://api.anthropic.com", models: [ { id: "claude-fable-5-1", label: "Claude Fable 5.1" }, @@ -79,7 +102,6 @@ export const MODEL_PROVIDERS: readonly ProviderEntry[] = [ kind: "api_key", label: "Google", hint: "A Gemini API key.", - needsBaseUrl: false, baseURL: "https://generativelanguage.googleapis.com", models: [ { id: "gemini-4-argon", label: "Gemini 4 Argon" }, @@ -94,7 +116,6 @@ export const MODEL_PROVIDERS: readonly ProviderEntry[] = [ quirks: { reasoning: { maxEffort: "xhigh" } }, label: "xAI API", hint: "An xAI API key, from console.x.ai.", - needsBaseUrl: false, baseURL: "https://api.x.ai/v1", models: [ { id: "grok-4.7", label: "Grok 4.7" }, @@ -113,7 +134,6 @@ export const MODEL_PROVIDERS: readonly ProviderEntry[] = [ kind: "login", label: "ChatGPT", hint: "Your ChatGPT subscription, signed in through OpenAI.", - needsBaseUrl: false, baseURL: "https://chatgpt.com/backend-api", models: [ { id: "gpt-6.1-sol", label: "gpt-6.1-sol" }, @@ -133,7 +153,6 @@ export const MODEL_PROVIDERS: readonly ProviderEntry[] = [ kind: "login", label: "xAI", hint: "Your xAI subscription, signed in through xAI.", - needsBaseUrl: false, baseURL: "https://cli-chat-proxy.grok.com/v1", models: [ { id: "grok-4.7", label: "Grok 4.7" }, @@ -146,27 +165,56 @@ export const MODEL_PROVIDERS: readonly ProviderEntry[] = [ }, ]; -export function modelProvider(id: string): ProviderEntry | undefined { - return MODEL_PROVIDERS.find((entry) => entry.id === id); +// The APIs a custom provider can speak, each served by the native adapter of that plugin. +export const CUSTOM_APIS: Readonly> = { + "openai-compatible": "OpenAI Chat Completions", + "openai-responses": "OpenAI Responses", + anthropic: "Anthropic Messages", +}; + +/** The providers a workspace can bind roles to: the shipped ones, then each custom provider. */ +export type ModelDirectory = readonly ProviderEntry[]; + +export function modelProvider( + directory: ModelDirectory, + id: string, +): ProviderEntry | undefined { + return directory.find((entry) => entry.id === id); } -/** - * The entry a catalog provider row is a connection of: the one at its plugin and fixed base URL, - * else the entry of that plugin a person points at their own base URL. - */ +/** The shipped entry a catalog provider row is a connection of: the one at its plugin and base URL. */ +export function shippedOfRow( + row: Readonly<{ plugin: string; baseURL: string | undefined }>, +): ShippedEntry | undefined { + return MODEL_PROVIDERS.find( + (entry) => entry.plugin === row.plugin && entry.baseURL === row.baseURL, + ); +} + +/** The custom provider running on a catalog provider row, else the shipped entry it connects. */ export function providerOfRow( - row: Readonly<{ plugin: string; baseURL: string }>, + directory: ModelDirectory, + row: Readonly<{ id: string; plugin: string; baseURL: string }>, ): ProviderEntry | undefined { - const ofPlugin = MODEL_PROVIDERS.filter( - (entry) => entry.plugin === row.plugin, - ); return ( - ofPlugin.find( - (entry) => !entry.needsBaseUrl && entry.baseURL === row.baseURL, - ) ?? ofPlugin.find((entry) => entry.needsBaseUrl) + directory.find( + (entry) => entry.kind === "custom" && entry.connection === row.id, + ) ?? shippedOfRow(row) ); } +/** The limits a role runs a bound model with: a custom model's own, else its shipped record. */ +export function bindingLimits( + directory: ModelDirectory, + provider: string, + model: string, +): ModelLimits | undefined { + const entry = modelProvider(directory, provider); + if (entry?.kind !== "custom") return modelLimits(provider, model); + const offered = entry.models.find((candidate) => candidate.id === model); + return offered && customModelLimits(offered); +} + // Each role a profile binds, the tier it runs on, the workflow that runs it, and the limits record // it budgets by. A person picks only a profile; the tier of each role is config. export const MODEL_ROLES: readonly Readonly<{ @@ -339,51 +387,66 @@ export const PROFILES: readonly Profile[] = [ ), ]; /** Why a binding cannot run: an unlisted provider or model, or a model without limits. */ -function unrunnable(binding: RoleBinding): string | undefined { - const provider = modelProvider(binding.provider); +function unrunnable( + directory: ModelDirectory, + binding: RoleBinding, +): string | undefined { + const provider = modelProvider(directory, binding.provider); if (!provider) return `provider:${binding.provider}`; if (!provider.models.some((model) => model.id === binding.model)) return `model:${binding.provider}:${binding.model}`; - if (!modelLimits(binding.provider, binding.model)) + if (!bindingLimits(directory, binding.provider, binding.model)) return `limits:${binding.provider}:${binding.model}`; return undefined; } for (const profile of PROFILES) for (const binding of Object.values(profile.tiers)) { - const reason = unrunnable(binding); + const reason = unrunnable(MODEL_PROVIDERS, binding); if (reason) throw new Error(`profile_unrunnable:${profile.id}:${reason}`); } /** The first part of a choice the profiles cannot run: its profile or a made profile's tier. */ -export function invalidModelChoice(choice: ModelChoice): string | undefined { +export function invalidModelChoice( + directory: ModelDirectory, + choice: ModelChoice, +): string | undefined { const custom = choice.custom ?? []; const ids = [...PROFILES, ...custom].map((profile) => profile.id); if (new Set(ids).size !== ids.length) return "duplicate_profile"; if (choice.profile !== undefined && !ids.includes(choice.profile)) return choice.profile; - for (const profile of custom) - for (const [tier, binding] of Object.entries(profile.tiers)) { - const reason = unrunnable(binding); - if (reason) return `${profile.id}:${tier}:${reason}`; + // A made profile nobody runs may name a provider since removed; only the chosen one must run. + const chosen = custom.find((profile) => profile.id === choice.profile); + if (chosen) { + for (const [tier, binding] of Object.entries(chosen.tiers)) { + const reason = unrunnable(directory, binding); + if (reason) return `${chosen.id}:${tier}:${reason}`; } + } return undefined; } /** The profile a choice names, when this server can run every tier of it. */ -function chosenProfile(choice: ModelChoice | null): CustomProfile | undefined { +function chosenProfile( + directory: ModelDirectory, + choice: ModelChoice | null, +): CustomProfile | undefined { const profile = choice?.custom?.find((entry) => entry.id === choice.profile) ?? PROFILES.find((entry) => entry.id === choice?.profile); return profile && - Object.values(profile.tiers).every((binding) => !unrunnable(binding)) + Object.values(profile.tiers).every( + (binding) => !unrunnable(directory, binding), + ) ? profile : undefined; } export type ModelChoiceRefusal = | "model_provider_missing" - | "model_profile_required"; + | "model_profile_required" + | "model_profile_custom"; // Why a project's coordinator cannot start without a model profile, as the API and UI show it. export const MODEL_CHOICE_REFUSALS: Readonly< @@ -393,6 +456,8 @@ export const MODEL_CHOICE_REFUSALS: Readonly< "No model provider is configured. Connect one in Settings, Providers, and the coordinator can start.", model_profile_required: "More than one model provider is connected. Choose a model profile in Settings, Profiles, and the coordinator can start.", + model_profile_custom: + "Only custom providers are connected, and they have no ready-made profile. Make a profile for your custom models in Settings, Profiles, and the coordinator can start.", }; /** @@ -401,20 +466,23 @@ export const MODEL_CHOICE_REFUSALS: Readonly< * there is no profile. */ export function resolveModelChoice( + directory: ModelDirectory, choice: ModelChoice | null, connected: readonly string[], ): { choice: ModelChoice } | { refused: ModelChoiceRefusal } { - const chosen = chosenProfile(choice); + const chosen = chosenProfile(directory, choice); if (chosen) return { choice: frozen(chosen) }; const providers = [...new Set(connected)]; const only = providers.length === 1 ? shippedProfileOf(providers[0]!) : undefined; if (only) return { choice: frozen(only) }; + if (providers.length === 0) return { refused: "model_provider_missing" }; return { - refused: - providers.length === 0 - ? "model_provider_missing" - : "model_profile_required", + refused: providers.every( + (id) => modelProvider(directory, id)?.kind === "custom", + ) + ? "model_profile_custom" + : "model_profile_required", }; } @@ -448,17 +516,19 @@ function frozen(profile: CustomProfile): ModelChoice { /** The frozen choice of a runnable choice; null when this server cannot run it. */ export function frozenModelChoice( + directory: ModelDirectory, choice: ModelChoice | null, ): ModelChoice | null { - const profile = chosenProfile(choice); + const profile = chosenProfile(directory, choice); return profile ? frozen(profile) : null; } /** The binding each workflow runs on, from a choice this server can run. */ export function workflowBindings( + directory: ModelDirectory, choice: ModelChoice, ): Record { - const profile = chosenProfile(choice); + const profile = chosenProfile(directory, choice); if (!profile) throw new Error(`model_choice_unrunnable:${choice.profile ?? ""}`); return Object.fromEntries( diff --git a/apps/hub/src/db/migrations/0036_custom_model_provider.sql b/apps/hub/src/db/migrations/0036_custom_model_provider.sql new file mode 100644 index 00000000..dc60cb95 --- /dev/null +++ b/apps/hub/src/db/migrations/0036_custom_model_provider.sql @@ -0,0 +1,10 @@ +CREATE TABLE "sb_custom_model_provider" ( + "id" text PRIMARY KEY NOT NULL, + "model_provider_id" text NOT NULL, + "name" text NOT NULL, + "models" jsonb NOT NULL, + "keyless" boolean NOT NULL, + CONSTRAINT "sb_custom_model_provider_model_provider_id_unique" UNIQUE("model_provider_id") +); +--> statement-breakpoint +ALTER TABLE "sb_custom_model_provider" ADD CONSTRAINT "sb_custom_model_provider_model_provider_id_model_provider_id_fk" FOREIGN KEY ("model_provider_id") REFERENCES "public"."model_provider"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/apps/hub/src/db/migrations/meta/0036_snapshot.json b/apps/hub/src/db/migrations/meta/0036_snapshot.json new file mode 100644 index 00000000..5b43ff1c --- /dev/null +++ b/apps/hub/src/db/migrations/meta/0036_snapshot.json @@ -0,0 +1,1154 @@ +{ + "id": "31045c86-dbe9-421a-a5b9-1a717da13cab", + "prevId": "d7ba8602-2460-4b8e-bdbc-d17af7642440", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.sb_artifact_approval": { + "name": "sb_artifact_approval", + "schema": "", + "columns": { + "approval_id": { + "name": "approval_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "artifact_id": { + "name": "artifact_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool": { + "name": "tool", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sb_artifact_approval_artifact_idx": { + "name": "sb_artifact_approval_artifact_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "artifact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sb_artifact_approval_approval_id_approval_id_fk": { + "name": "sb_artifact_approval_approval_id_approval_id_fk", + "tableFrom": "sb_artifact_approval", + "tableTo": "approval", + "columnsFrom": ["approval_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "sb_artifact_approval_tenant_id_tenant_id_fk": { + "name": "sb_artifact_approval_tenant_id_tenant_id_fk", + "tableFrom": "sb_artifact_approval", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "sb_artifact_approval_project_id_project_id_fk": { + "name": "sb_artifact_approval_project_id_project_id_fk", + "tableFrom": "sb_artifact_approval", + "tableTo": "project", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sb_artifact_approval_approval_id_artifact_id_pk": { + "name": "sb_artifact_approval_approval_id_artifact_id_pk", + "columns": ["approval_id", "artifact_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sb_artifact_import_approval": { + "name": "sb_artifact_import_approval", + "schema": "", + "columns": { + "artifact_id": { + "name": "artifact_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sb_artifact_import_approval_tenant_idx": { + "name": "sb_artifact_import_approval_tenant_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "artifact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sb_artifact_import_approval_tenant_id_tenant_id_fk": { + "name": "sb_artifact_import_approval_tenant_id_tenant_id_fk", + "tableFrom": "sb_artifact_import_approval", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "sb_artifact_import_approval_project_id_project_id_fk": { + "name": "sb_artifact_import_approval_project_id_project_id_fk", + "tableFrom": "sb_artifact_import_approval", + "tableTo": "project", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "sb_artifact_import_approval_principal_id_principal_id_fk": { + "name": "sb_artifact_import_approval_principal_id_principal_id_fk", + "tableFrom": "sb_artifact_import_approval", + "tableTo": "principal", + "columnsFrom": ["principal_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sb_artifact_import_approval_artifact_id_version_pk": { + "name": "sb_artifact_import_approval_artifact_id_version_pk", + "columns": ["artifact_id", "version"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sb_custom_model_provider": { + "name": "sb_custom_model_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "model_provider_id": { + "name": "model_provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "models": { + "name": "models", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "keyless": { + "name": "keyless", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sb_custom_model_provider_model_provider_id_model_provider_id_fk": { + "name": "sb_custom_model_provider_model_provider_id_model_provider_id_fk", + "tableFrom": "sb_custom_model_provider", + "tableTo": "model_provider", + "columnsFrom": ["model_provider_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sb_custom_model_provider_model_provider_id_unique": { + "name": "sb_custom_model_provider_model_provider_id_unique", + "nullsNotDistinct": false, + "columns": ["model_provider_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sb_design_concept": { + "name": "sb_design_concept", + "schema": "", + "columns": { + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sb_design_concept_tenant_idx": { + "name": "sb_design_concept_tenant_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "label", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sb_design_concept_run_id_workflow_run_id_fk": { + "name": "sb_design_concept_run_id_workflow_run_id_fk", + "tableFrom": "sb_design_concept", + "tableTo": "workflow_run", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "sb_design_concept_tenant_id_tenant_id_fk": { + "name": "sb_design_concept_tenant_id_tenant_id_fk", + "tableFrom": "sb_design_concept", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "sb_design_concept_project_id_project_id_fk": { + "name": "sb_design_concept_project_id_project_id_fk", + "tableFrom": "sb_design_concept", + "tableTo": "project", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_code": { + "name": "device_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "device_code_device_code_unique": { + "name": "device_code_device_code_unique", + "nullsNotDistinct": false, + "columns": ["device_code"] + }, + "device_code_user_code_unique": { + "name": "device_code_user_code_unique", + "nullsNotDistinct": false, + "columns": ["user_code"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_session": { + "name": "device_session", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "device_session_session_id_session_id_fk": { + "name": "device_session_session_id_session_id_fk", + "tableFrom": "device_session", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sb_ended_run": { + "name": "sb_ended_run", + "schema": "", + "columns": { + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notified": { + "name": "notified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sb_ended_run_run_id_workflow_run_id_fk": { + "name": "sb_ended_run_run_id_workflow_run_id_fk", + "tableFrom": "sb_ended_run", + "tableTo": "workflow_run", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "sb_ended_run_project_id_project_id_fk": { + "name": "sb_ended_run_project_id_project_id_fk", + "tableFrom": "sb_ended_run", + "tableTo": "project", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sb_invitation": { + "name": "sb_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_sha256": { + "name": "role_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_sha256": { + "name": "payload_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_principal_id": { + "name": "accepted_principal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sb_invitation_recipient_idx": { + "name": "sb_invitation_recipient_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sb_invitation_tenant_id_tenant_id_fk": { + "name": "sb_invitation_tenant_id_tenant_id_fk", + "tableFrom": "sb_invitation", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sb_invitation_tenant_id_invited_by_idempotency_key_unique": { + "name": "sb_invitation_tenant_id_invited_by_idempotency_key_unique", + "nullsNotDistinct": false, + "columns": ["tenant_id", "invited_by", "idempotency_key"] + } + }, + "policies": {}, + "checkConstraints": { + "sb_invitation_terminal_check": { + "name": "sb_invitation_terminal_check", + "value": "NOT (\"sb_invitation\".\"accepted_at\" IS NOT NULL AND \"sb_invitation\".\"revoked_at\" IS NOT NULL)" + }, + "sb_invitation_acceptance_check": { + "name": "sb_invitation_acceptance_check", + "value": "(\"sb_invitation\".\"accepted_at\" IS NULL) = (\"sb_invitation\".\"accepted_principal_id\" IS NULL)" + }, + "sb_invitation_revocation_check": { + "name": "sb_invitation_revocation_check", + "value": "(\"sb_invitation\".\"revoked_at\" IS NULL) = (\"sb_invitation\".\"revoked_by\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.mutation_receipt": { + "name": "mutation_receipt", + "schema": "", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_principal_id": { + "name": "actor_principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_sha256": { + "name": "payload_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "result_body": { + "name": "result_body", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "mutation_receipt_tenant_id_tenant_id_fk": { + "name": "mutation_receipt_tenant_id_tenant_id_fk", + "tableFrom": "mutation_receipt", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "mutation_receipt_actor_principal_id_principal_id_fk": { + "name": "mutation_receipt_actor_principal_id_principal_id_fk", + "tableFrom": "mutation_receipt", + "tableTo": "principal", + "columnsFrom": ["actor_principal_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mutation_receipt_tenant_id_actor_principal_id_command_idempotency_key_pk": { + "name": "mutation_receipt_tenant_id_actor_principal_id_command_idempotency_key_pk", + "columns": [ + "tenant_id", + "actor_principal_id", + "command", + "idempotency_key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox": { + "name": "outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sb_outbox_project_time_id_idx": { + "name": "sb_outbox_project_time_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "outbox_project_id_project_id_fk": { + "name": "outbox_project_id_project_id_fk", + "tableFrom": "outbox", + "tableTo": "project", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "version_of_project_id": { + "name": "version_of_project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shipped_at": { + "name": "shipped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_tenant_id_tenant_id_fk": { + "name": "project_tenant_id_tenant_id_fk", + "tableFrom": "project", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "project_version_of_project_id_project_id_fk": { + "name": "project_version_of_project_id_project_id_fk", + "tableFrom": "project", + "tableTo": "project", + "columnsFrom": ["version_of_project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_tenant_id_unique": { + "name": "project_tenant_id_unique", + "nullsNotDistinct": false, + "columns": ["tenant_id"] + } + }, + "policies": {}, + "checkConstraints": { + "project_status_check": { + "name": "project_status_check", + "value": "\"project\".\"status\" in ('active', 'archived')" + } + }, + "isRLSEnabled": false + }, + "public.project_coordinator": { + "name": "project_coordinator", + "schema": "", + "columns": { + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "registered_by": { + "name": "registered_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_coordinator_active_project_idx": { + "name": "project_coordinator_active_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"project_coordinator\".\"active\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_coordinator_run_id_workflow_run_id_fk": { + "name": "project_coordinator_run_id_workflow_run_id_fk", + "tableFrom": "project_coordinator", + "tableTo": "workflow_run", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "project_coordinator_project_id_project_id_fk": { + "name": "project_coordinator_project_id_project_id_fk", + "tableFrom": "project_coordinator", + "tableTo": "project", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "project_coordinator_registered_by_principal_id_fk": { + "name": "project_coordinator_registered_by_principal_id_fk", + "tableFrom": "project_coordinator", + "tableTo": "principal", + "columnsFrom": ["registered_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sb_settings": { + "name": "sb_settings", + "schema": "", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fields": { + "name": "fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sb_settings_tenant_id_tenant_id_fk": { + "name": "sb_settings_tenant_id_tenant_id_fk", + "tableFrom": "sb_settings", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "sb_settings_project_id_project_id_fk": { + "name": "sb_settings_project_id_project_id_fk", + "tableFrom": "sb_settings", + "tableTo": "project", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sb_settings_tenant_id_scope_key_pk": { + "name": "sb_settings_tenant_id_scope_key_pk", + "columns": ["tenant_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/hub/src/db/migrations/meta/_journal.json b/apps/hub/src/db/migrations/meta/_journal.json index ef002f3b..c5469e28 100644 --- a/apps/hub/src/db/migrations/meta/_journal.json +++ b/apps/hub/src/db/migrations/meta/_journal.json @@ -253,6 +253,13 @@ "when": 1793900000000, "tag": "0035_drop_deck_looks", "breakpoints": true + }, + { + "idx": 36, + "version": "7", + "when": 1794000000000, + "tag": "0036_custom_model_provider", + "breakpoints": true } ] } diff --git a/apps/hub/src/db/schema.ts b/apps/hub/src/db/schema.ts index bfe669c4..e8b5640e 100644 --- a/apps/hub/src/db/schema.ts +++ b/apps/hub/src/db/schema.ts @@ -13,7 +13,14 @@ import { unique, uniqueIndex, } from "drizzle-orm/pg-core"; -import { approval, principal, tenant, workflowRun } from "@intx/db/schema"; +import { + approval, + modelProvider, + principal, + tenant, + workflowRun, +} from "@intx/db/schema"; +import type { CustomModel } from "@corbits-builder/schemas/models"; export const project = pgTable( "project", @@ -283,3 +290,18 @@ export const settingsRecord = pgTable( }, (t) => [primaryKey({ columns: [t.tenantId, t.scopeKey] })], ); + +// A person's own endpoint: its name and models, and the catalog connection it runs +// on, which holds its base URL and key. Its id is what profiles bind, so it survives moving to a +// new connection. +export const customModelProvider = pgTable("sb_custom_model_provider", { + id: text("id").primaryKey(), + modelProviderId: text("model_provider_id") + .notNull() + .unique() + .references(() => modelProvider.id, { onDelete: "cascade" }), + name: text("name").notNull(), + models: jsonb("models").$type().notNull(), + // Its connection carries a placeholder in place of a key; the placeholder itself is never shown. + keyless: boolean("keyless").notNull(), +}); diff --git a/apps/hub/src/inference/model-limits.ts b/apps/hub/src/inference/model-limits.ts index 13c5934d..d5d54d99 100644 --- a/apps/hub/src/inference/model-limits.ts +++ b/apps/hub/src/inference/model-limits.ts @@ -3,6 +3,8 @@ // still fits. A missing record fails closed. How hard each role reasons is the profile's choice // (config/models.ts), not the model's. +import type { CustomModel } from "@corbits-builder/schemas/models"; + export type AgentRole = "coordinator" | "document" | "coding" | "design"; type Effort = "low" | "medium" | "high" | "max"; @@ -12,7 +14,7 @@ type RoleBudget = { temperature?: number; }; -type ModelLimits = { +export type ModelLimits = { contextWindow: number; /** The efforts the provider's adapter puts on the wire for this model; empty sends none. */ efforts: readonly Effort[]; @@ -125,25 +127,17 @@ export function modelLimits( return found; } -export function modelEfforts( - provider: string, - model: string, -): readonly Effort[] { - return modelLimits(provider, model)?.efforts ?? []; -} - -export function requireContextWindow(provider: string, model: string): number { - const found = modelLimits(provider, model); - if (!found) throw new Error(`model_limits_required:${provider}:${model}`); - return found.contextWindow; -} - -export function requireModelRole( - provider: string, - model: string, - role: AgentRole, -): RoleBudget { - const budget = modelLimits(provider, model)?.roles[role]; - if (!budget) throw new Error(`model_limits_required:${provider}:${model}`); - return budget; +// A person's own model runs every role on the one output cap they set, and is sent no effort. +export function customModelLimits(model: CustomModel): ModelLimits { + const budget: RoleBudget = { maxOutputTokens: model.maxOutputTokens }; + return { + contextWindow: model.contextWindow, + efforts: [], + roles: { + coordinator: budget, + document: budget, + coding: budget, + design: budget, + }, + }; } diff --git a/apps/hub/src/install-root.ts b/apps/hub/src/install-root.ts index ac904d91..99c19756 100644 --- a/apps/hub/src/install-root.ts +++ b/apps/hub/src/install-root.ts @@ -14,7 +14,10 @@ import { generateId } from "@intx/hub-common"; import { getLogger } from "@intx/log"; import { credentialAad, type CredentialCipher } from "@intx/types"; import { installRoot, type ServerProviderKeys } from "./config/install-root.ts"; -import { modelProvider as providerEntry } from "./config/models.ts"; +import { + MODEL_PROVIDERS, + modelProvider as providerEntry, +} from "./config/models.ts"; const log = getLogger(["corbits-builder", "hub", "install-root"]); @@ -62,7 +65,7 @@ async function seedProviderKey( tenantId: string, key: ServerProviderKeys["keys"][number], ) { - const entry = providerEntry(key.provider)!; + const entry = providerEntry(MODEL_PROVIDERS, key.provider)!; const baseURL = key.baseURL ?? entry.baseURL; const credentialName = `provider:${entry.id}`; await db.transaction(async (tx) => { diff --git a/apps/hub/src/projects/model-choice.ts b/apps/hub/src/projects/model-choice.ts index bf2bca76..2c341196 100644 --- a/apps/hub/src/projects/model-choice.ts +++ b/apps/hub/src/projects/model-choice.ts @@ -7,6 +7,7 @@ import { frozenModelChoice, providerOfRow, resolveModelChoice, + type ModelDirectory, } from "../config/models.ts"; import { settingsRecord } from "../db/schema.ts"; import { @@ -21,11 +22,12 @@ import type { Actor } from "../lib/actor.ts"; /** The provider entries a tenant can run on: each enabled connection with a credential. */ export async function connectedProviders( db: DB["db"], + directory: ModelDirectory, tenantId: string, ): Promise { const ids = (await listVisibleProviders(db, tenantId)) .filter(({ row }) => row.credentialId !== null && !row.disabled) - .map(({ row }) => providerOfRow(row)?.id) + .map(({ row }) => providerOfRow(directory, row)?.id) .filter((id) => id !== undefined); return [...new Set(ids)]; } @@ -52,10 +54,11 @@ async function personalChoice( // A choice runs only when every tier's provider is connected in this workspace, not merely offered. function frozenOn( + directory: ModelDirectory, choice: ModelChoice | null, connected: readonly string[], ): ModelChoice | null { - const kept = frozenModelChoice(choice); + const kept = frozenModelChoice(directory, choice); const profile = kept?.custom?.find((entry) => entry.id === kept.profile); return profile && Object.values(profile.tiers).every((binding) => @@ -74,13 +77,15 @@ export async function projectModelChoice( db: DB["db"], actor: Actor, projectId: string, + directory: ModelDirectory, ): Promise { const { models } = await loadSettings(db, actor.tenantId, projectId); - const connected = await connectedProviders(db, actor.tenantId); - const kept = frozenOn(models.value, connected); + const connected = await connectedProviders(db, directory, actor.tenantId); + const kept = frozenOn(directory, models.value, connected); if (kept) return kept; const resolved = resolveModelChoice( - frozenOn(await personalChoice(db, actor), connected), + directory, + frozenOn(directory, await personalChoice(db, actor), connected), connected, ); if ("refused" in resolved) diff --git a/apps/hub/src/projects/service.ts b/apps/hub/src/projects/service.ts index db899212..6833ef2c 100644 --- a/apps/hub/src/projects/service.ts +++ b/apps/hub/src/projects/service.ts @@ -34,6 +34,7 @@ import { } from "../settings/policy.ts"; import { resolvePreferences } from "../config/preferences.ts"; import { frozenModelChoice, pickedModelChoice } from "../config/models.ts"; +import { modelDirectory } from "../settings/custom-providers.ts"; import { parseStatusFilter } from "./lifecycle.ts"; import { grantAuthorization, @@ -541,6 +542,7 @@ export function createProjectService(db: DB["db"]) { mutation.payload.title, parentId, ); + const directory = await modelDirectory(db, child.id); const result = await db.transaction(async (tx) => { await tx.execute( sql`select pg_advisory_xact_lock(hashtextextended(${canonicalJsonStringify([caller.tenantId, caller.principalId, mutation.command, mutation.idempotencyKey])}, 0))`, @@ -590,7 +592,7 @@ export function createProjectService(db: DB["db"]) { value: resolvePreferences(preferences.value), version: 1, }, - models: { value: frozenModelChoice(choice), version: 1 }, + models: { value: frozenModelChoice(directory, choice), version: 1 }, }, }); const recorded = await record( diff --git a/apps/hub/src/settings/custom-providers.ts b/apps/hub/src/settings/custom-providers.ts new file mode 100644 index 00000000..a0cc0a84 --- /dev/null +++ b/apps/hub/src/settings/custom-providers.ts @@ -0,0 +1,340 @@ +import { and, eq, inArray } from "drizzle-orm"; +import type { Context } from "hono"; +import { type } from "arktype"; +import { listVisibleProviders, type DB } from "@intx/db"; +import { credential, modelProvider, provider } from "@intx/db/schema"; +import type { TenantEnv } from "@intx/hub-api"; +import { getLogger } from "@intx/log"; +import { + CustomApi, + CustomModel, + type CustomProviderCreate, + type CustomProviderUpdate, +} from "@corbits-builder/schemas/models"; +import { + CUSTOM_APIS, + MODEL_PROVIDERS, + type ModelDirectory, +} from "../config/models.ts"; +import { customModelProvider } from "../db/schema.ts"; +import { ProjectError } from "../projects/errors.ts"; +import { readJson } from "../lib/json.ts"; +import { requestErrorMetadata } from "../request-error.ts"; + +const log = getLogger(["corbits-builder", "hub", "custom-providers"]); +const storedModels = CustomModel.array(); +const Created = type({ id: "string" }); +const NativeError = type({ error: { code: "string" } }); +const NATIVE_REFUSALS = [400, 403, 404, 409, 413] as const; +// Interchange requires a key on every catalog connection; a keyless server is sent this one. +const KEYLESS_SECRET = "builder-keyless"; + +/** The shipped providers and each custom provider this tenant sees, with its models. */ +export async function modelDirectory( + db: DB["db"], + tenantId: string, +): Promise { + const rows = (await listVisibleProviders(db, tenantId)).map(({ row }) => row); + if (rows.length === 0) return MODEL_PROVIDERS; + const saved = await db + .select() + .from(customModelProvider) + .where( + inArray( + customModelProvider.modelProviderId, + rows.map((row) => row.id), + ), + ); + return [ + ...MODEL_PROVIDERS, + ...saved.flatMap((entry) => { + const row = rows.find(({ id }) => id === entry.modelProviderId); + if (!row) return []; + const models = storedModels(entry.models); + if (models instanceof type.errors) { + log.warn("Skipping custom provider {id}: stored models are invalid", { + id: entry.id, + errors: models.summary, + }); + return []; + } + return [ + { + id: entry.id, + plugin: row.plugin, + kind: "custom" as const, + connection: row.id, + keyless: entry.keyless, + label: entry.name, + hint: CUSTOM_APIS[CustomApi.assert(row.plugin)], + baseURL: row.baseURL, + models, + }, + ]; + }), + ]; +} + +type Native = ( + method: "POST" | "DELETE", + path: string, + body?: Record, +) => Promise; + +/** + * Interchange's own provider, credential and catalog routes, called as the person, so their checks, + * grant cleanup and pushes to running agents happen as they do for any other provider. + */ +export function nativeCatalog( + c: Context, + nativeFetch: (request: Request) => Promise, +): Native { + const root = `/api/tenants/${encodeURIComponent(c.get("tenant").id)}`; + return async (method, path, body) => { + const headers = new Headers({ "content-type": "application/json" }); + for (const name of ["cookie", "authorization"]) { + const value = c.req.header(name); + if (value) headers.set(name, value); + } + const response = await nativeFetch( + new Request(new URL(`${root}${path}`, c.req.url).href, { + method, + headers, + ...(body ? { body: JSON.stringify(body) } : {}), + }), + ); + const refusal = NATIVE_REFUSALS.find((code) => code === response.status); + if (refusal) { + const refused = NativeError(await readJson(response)); + throw new ProjectError( + refusal, + refused instanceof type.errors ? "catalog_refused" : refused.error.code, + ); + } + if (!response.ok) + throw new Error(`catalog_write_failed:${method}:${response.status}`); + return response.status === 204 ? undefined : readJson(response); + }; +} + +const at = (path: string, id: string) => `${path}/${encodeURIComponent(id)}`; + +// What a custom provider runs on: its catalog connection, that connection's key, and the native +// provider the key is pinned to. +type Connection = Readonly<{ + modelProviderId: string; + credentialId: string; + originId: string; +}>; + +/** Runs every step, so one failure leaves none of the others undone, and returns the failures. */ +async function everyStep( + steps: readonly (() => Promise)[], +): Promise { + const failures: unknown[] = []; + for (const step of steps) + try { + await step(); + } catch (error) { + failures.push(error); + } + return failures; +} + +function withCleanup(error: unknown, failures: readonly unknown[]): unknown { + return failures.length === 0 + ? error + : new AggregateError( + [error, ...failures], + "custom_provider_cleanup_failed", + ); +} + +// A credential reaches only its native provider's API origin, so each API and origin has one. +async function originProvider( + db: DB["db"], + native: Native, + tenantId: string, + api: CustomApi, + baseURL: string, +): Promise { + const origin = new URL(baseURL).origin; + const name = `byok-${api} ${origin}`; + const [found] = await db + .select({ id: provider.id }) + .from(provider) + .where(and(eq(provider.tenantId, tenantId), eq(provider.name, name))); + if (found) return found.id; + return Created.assert( + await native("POST", "/providers", { + name, + plugin: api, + apiBaseUrl: origin, + }), + ).id; +} + +async function dropUnusedOrigin(db: DB["db"], native: Native, id: string) { + const [used] = await db + .select({ id: credential.id }) + .from(credential) + .where(eq(credential.providerId, id)) + .limit(1); + if (!used) await native("DELETE", at("/providers", id)); +} + +/** A new catalog connection with its own key; a failed step undoes the ones before it. */ +async function connect( + db: DB["db"], + native: Native, + tenantId: string, + connection: CustomProviderCreate["connection"], +): Promise { + const { api, baseURL } = connection; + const originId = await originProvider(db, native, tenantId, api, baseURL); + const undo: (() => Promise)[] = [ + () => dropUnusedOrigin(db, native, originId), + ]; + try { + const credentialId = Created.assert( + await native("POST", "/credentials", { + providerId: originId, + name: `custom-provider ${crypto.randomUUID()}`, + type: "api_key", + secret: "secret" in connection ? connection.secret : KEYLESS_SECRET, + }), + ).id; + undo.unshift(() => native("DELETE", at("/credentials", credentialId))); + const modelProviderId = Created.assert( + await native("POST", "/catalog/providers", { + name: `custom-provider ${crypto.randomUUID()}`, + plugin: api, + baseURL, + credentialId, + }), + ).id; + return { modelProviderId, credentialId, originId }; + } catch (error) { + throw withCleanup(error, await everyStep(undo)); + } +} + +function disconnect(db: DB["db"], native: Native, from: Connection) { + return everyStep([ + () => native("DELETE", at("/catalog/providers", from.modelProviderId)), + ...releaseKey(db, native, from), + ]); +} + +// The key and its origin, once nothing runs on them; leftovers are logged, not refused. +function releaseKey(db: DB["db"], native: Native, from: Connection) { + return [ + () => native("DELETE", at("/credentials", from.credentialId)), + () => dropUnusedOrigin(db, native, from.originId), + ]; +} + +function logLeftovers(id: string, failures: readonly unknown[]) { + for (const failure of failures) + log.warn("Custom provider {id} left part of its old connection", { + id, + ...requestErrorMetadata(failure), + }); +} + +/** A custom provider this tenant owns, with its connection; a parent's is not found. */ +async function owned( + db: DB["db"], + tenantId: string, + id: string, +): Promise { + const [row] = await db + .select({ + modelProviderId: modelProvider.id, + credentialId: credential.id, + originId: credential.providerId, + }) + .from(customModelProvider) + .innerJoin( + modelProvider, + eq(modelProvider.id, customModelProvider.modelProviderId), + ) + .innerJoin(credential, eq(credential.id, modelProvider.credentialId)) + .where( + and(eq(customModelProvider.id, id), eq(modelProvider.tenantId, tenantId)), + ); + if (!row) throw new ProjectError(404, "custom_provider_not_found"); + return row; +} + +/** Makes the catalog connection and its key, then keeps the name and models beside them. */ +export async function createCustomProvider( + db: DB["db"], + native: Native, + tenantId: string, + input: CustomProviderCreate, +): Promise<{ id: string }> { + const connection = await connect(db, native, tenantId, input.connection); + const id = crypto.randomUUID(); + try { + await db.insert(customModelProvider).values({ + id, + modelProviderId: connection.modelProviderId, + name: input.name, + models: input.models, + keyless: "keyless" in input.connection, + }); + } catch (error) { + throw withCleanup(error, await disconnect(db, native, connection)); + } + return { id }; +} + +/** + * Renames a custom provider and replaces its models. A new connection is made first and the old + * one removed once the provider runs on the new one, so a failed removal is logged, not refused. + */ +export async function updateCustomProvider( + db: DB["db"], + native: Native, + tenantId: string, + id: string, + input: CustomProviderUpdate, +): Promise { + const current = await owned(db, tenantId, id); + const saved = { name: input.name, models: input.models }; + const where = eq(customModelProvider.id, id); + if (!input.connection) { + await db.update(customModelProvider).set(saved).where(where); + return; + } + const next = await connect(db, native, tenantId, input.connection); + try { + await db + .update(customModelProvider) + .set({ + ...saved, + modelProviderId: next.modelProviderId, + keyless: "keyless" in input.connection, + }) + .where(where); + } catch (error) { + throw withCleanup(error, await disconnect(db, native, next)); + } + logLeftovers(id, await disconnect(db, native, current)); +} + +/** + * Removes a custom provider's connection, and its name and models with it; the key is then + * released best effort. + */ +export async function removeCustomProvider( + db: DB["db"], + native: Native, + tenantId: string, + id: string, +): Promise { + const current = await owned(db, tenantId, id); + await native("DELETE", at("/catalog/providers", current.modelProviderId)); + logLeftovers(id, await everyStep(releaseKey(db, native, current))); +} diff --git a/apps/hub/src/settings/routes.ts b/apps/hub/src/settings/routes.ts index 91b50fd9..1c07efd1 100644 --- a/apps/hub/src/settings/routes.ts +++ b/apps/hub/src/settings/routes.ts @@ -23,6 +23,10 @@ import { } from "@corbits-builder/schemas/settings"; import { PreferenceRegistry } from "@corbits-builder/schemas/preferences"; import { + CustomApi, + CustomProviderCreate, + CustomProviderReceipt, + CustomProviderUpdate, ModelCatalog, ProviderTest, ProviderTestResult, @@ -31,15 +35,24 @@ import { type } from "arktype"; import { preferenceRegistry } from "../config/preferences.ts"; import type { ProductConfig } from "../config/index.ts"; import { + CUSTOM_APIS, EFFORTS, MODEL_CHOICE_REFUSALS, MODEL_ROLES, PROFILES, TIERS, - MODEL_PROVIDERS, providerOfRow, resolveModelChoice, + shippedOfRow, } from "../config/models.ts"; +import { + createCustomProvider, + modelDirectory, + nativeCatalog, + removeCustomProvider, + updateCustomProvider, +} from "./custom-providers.ts"; +import { readJson } from "../lib/json.ts"; import { connectedProviders } from "../projects/model-choice.ts"; import { createAvailabilityProbe, @@ -89,10 +102,16 @@ export function createSettingsRoutes( cipher: CredentialCipher, config: ProductConfig, installRootId: string, + nativeFetch: (request: Request) => Promise, ) { const app = new Hono(); const service = createSettingsService(db); const probe = createAvailabilityProbe(db, cipher); + const requireManage = async (c: Context) => { + const allowed = await reportAuthorization(db, actor(c)); + if (!(await allowed("sb-settings:tenant", "manage"))) + throw new ProjectError(403, "forbidden"); + }; app.use("*", requireAuth, createResolveTenant({ db })); app.use( "*", @@ -130,6 +149,7 @@ export function createSettingsRoutes( const allowed = await reportAuthorization(db, actor(c)); if (!(await allowed("sb-settings:tenant", "read"))) throw new ProjectError(403, "forbidden"); + const directory = await modelDirectory(db, tenantId); const providers = []; for (const { row } of await listVisibleProviders(db, tenantId)) { if (!(await allowed(`model-provider:${row.id}`, "read"))) continue; @@ -143,10 +163,15 @@ export function createSettingsRoutes( providers.push({ id: row.id, name: row.name, - availability: settingsAvailability( - row.plugin, - await probe(tenantId, row.id), - ), + // A custom endpoint is an address a person typed, so it is probed only when they ask. + ...(providerOfRow(directory, row)?.kind === "custom" + ? {} + : { + availability: settingsAvailability( + row.plugin, + await probe(tenantId, row.id), + ), + }), secrets: { credential: { set: Boolean(credential) } }, }); } @@ -235,21 +260,32 @@ export function createSettingsRoutes( if (!(await allowed("sb-settings:tenant", "read"))) throw new ProjectError(403, "forbidden"); const { tenantId } = actor(c); + const directory = await modelDirectory(db, tenantId); const visible = []; for (const { row, origin } of await listVisibleProviders(db, tenantId)) if (await allowed(`model-provider:${row.id}`, "read")) visible.push({ row, fromServer: origin.tenantId === installRootId }); const connections = await Promise.all( - visible.map(async ({ row, fromServer }) => ({ - entry: providerOfRow(row)?.id, - id: row.id, - name: row.name, - status: row.disabled - ? ("unavailable" as const) - : await probe(tenantId, row.id), - credentialSet: row.credentialId !== null, - ...(fromServer ? { note: installRoot.connectionNote } : {}), - })), + visible.map(async ({ row, fromServer }) => { + const entry = providerOfRow(directory, row); + return { + entry: entry?.id, + id: row.id, + name: row.name, + ...(entry?.kind === "custom" + ? { + keyless: entry.keyless, + ...(row.disabled ? { status: "unavailable" as const } : {}), + } + : { + status: row.disabled + ? ("unavailable" as const) + : await probe(tenantId, row.id), + }), + credentialSet: row.credentialId !== null, + ...(fromServer ? { note: installRoot.connectionNote } : {}), + }; + }), ); const answering = new Set( connections @@ -257,21 +293,34 @@ export function createSettingsRoutes( .map((row) => row.entry), ); const automatic = resolveModelChoice( + directory, null, - await connectedProviders(db, tenantId), + await connectedProviders(db, directory, tenantId), ); return c.json({ - providers: MODEL_PROVIDERS.map(({ quirks: _quirks, ...entry }) => ({ - ...entry, - models: [...entry.models], - // A stored row name is the catalog's unique key, not something a person chose. - connections: connections - .filter((connection) => connection.entry === entry.id) - .map(({ entry: _entry, ...connection }, index) => ({ - ...connection, - name: `${entry.label}${index === 0 ? "" : ` ${index + 1}`}`, - })), - })), + providers: directory + // A custom provider shows only to someone who may read the connection it runs on. + .filter( + (entry) => + entry.kind !== "custom" || + connections.some(({ id }) => id === entry.connection), + ) + .map((entry) => ({ + id: entry.id, + plugin: entry.plugin, + kind: entry.kind, + label: entry.label, + hint: entry.hint, + baseURL: entry.baseURL, + models: [...entry.models], + // A stored row name is the catalog's unique key, not something a person chose. + connections: connections + .filter((connection) => connection.entry === entry.id) + .map(({ entry: _entry, ...connection }, index) => ({ + ...connection, + name: `${entry.label}${index === 0 ? "" : ` ${index + 1}`}`, + })), + })), roles: MODEL_ROLES.map(({ id, label, hint, tier }) => ({ id, label, @@ -280,6 +329,10 @@ export function createSettingsRoutes( })), tiers: [...TIERS], efforts: [...EFFORTS], + customApis: Object.entries(CUSTOM_APIS).map(([id, label]) => ({ + id, + label, + })), ...("refused" in automatic ? { unresolved: { @@ -324,22 +377,129 @@ export function createSettingsRoutes( const input = ProviderTest(await c.req.json().catch(() => null)); if (input instanceof type.errors) throw new ProjectError(400, "invalid_provider_test"); - const entry = providerOfRow({ - plugin: input.plugin, - baseURL: input.baseURL?.trim() ?? "", - }); - if (!entry) throw new ProjectError(400, "unknown_provider"); - if (entry.kind === "login") + const baseURL = input.baseURL?.trim(); + const entry = shippedOfRow({ plugin: input.plugin, baseURL }); + if (!entry && !CustomApi.allows(input.plugin)) + throw new ProjectError(400, "unknown_provider"); + if (entry?.kind === "login") throw new ProjectError(400, "provider_signs_in"); - const baseURL = entry.needsBaseUrl - ? input.baseURL?.trim() - : entry.baseURL; + if (entry && !("secret" in input)) + throw new ProjectError(400, "secret_required"); if (!baseURL) throw new ProjectError(400, "base_url_required"); return c.json({ - status: await probeKey(entry.plugin, baseURL, input.secret), + status: await probeKey( + input.plugin, + baseURL, + "secret" in input ? input.secret : undefined, + ), }); }, ); + app.post( + "/custom-providers", + describeRoute({ + tags: ["Settings"], + operationId: "createCustomProvider", + requestBody: { + required: true, + content: { + "application/json": { schema: resolver(CustomProviderCreate) }, + }, + }, + responses: { + 200: { + description: "The new custom provider; the key is not returned", + content: { + "application/json": { schema: resolver(CustomProviderReceipt) }, + }, + }, + 400: { description: "Invalid name, base URL or models" }, + }, + }), + async (c) => { + await requireManage(c); + if (!isJsonRequest(c)) + return c.json({ code: "json_content_type_required" }, 415); + const input = CustomProviderCreate(await readJson(c.req)); + if (input instanceof type.errors) + throw new ProjectError(400, "invalid_custom_provider"); + return c.json( + await createCustomProvider( + db, + nativeCatalog(c, nativeFetch), + actor(c).tenantId, + input, + ), + ); + }, + ); + app.post( + "/custom-providers/:providerId", + describeRoute({ + tags: ["Settings"], + operationId: "updateCustomProvider", + requestBody: { + required: true, + content: { + "application/json": { schema: resolver(CustomProviderUpdate) }, + }, + }, + responses: { + 200: { + description: "The custom provider as kept", + content: { + "application/json": { schema: resolver(CustomProviderReceipt) }, + }, + }, + 400: { description: "Invalid name, base URL or models" }, + 404: { description: "This workspace owns no such custom provider" }, + }, + }), + async (c) => { + await requireManage(c); + if (!isJsonRequest(c)) + return c.json({ code: "json_content_type_required" }, 415); + const input = CustomProviderUpdate(await readJson(c.req)); + if (input instanceof type.errors) + throw new ProjectError(400, "invalid_custom_provider"); + const id = c.req.param("providerId"); + await updateCustomProvider( + db, + nativeCatalog(c, nativeFetch), + actor(c).tenantId, + id, + input, + ); + return c.json({ id }); + }, + ); + app.delete( + "/custom-providers/:providerId", + describeRoute({ + tags: ["Settings"], + operationId: "removeCustomProvider", + responses: { + 200: { + description: "The connection, its key and its models are removed", + content: { + "application/json": { schema: resolver(CustomProviderReceipt) }, + }, + }, + 404: { description: "This workspace owns no such custom provider" }, + }, + }), + async (c) => { + await requireManage(c); + const id = c.req.param("providerId"); + await removeCustomProvider( + db, + nativeCatalog(c, nativeFetch), + actor(c).tenantId, + id, + ); + return c.json({ id }); + }, + ); app.get( "/me", describeRoute({ diff --git a/apps/hub/src/settings/service.ts b/apps/hub/src/settings/service.ts index 19ae7d45..54dfccb7 100644 --- a/apps/hub/src/settings/service.ts +++ b/apps/hub/src/settings/service.ts @@ -26,6 +26,7 @@ import { } from "./policy.ts"; import { invalidPreference } from "../config/preferences.ts"; import { invalidModelChoice } from "../config/models.ts"; +import { modelDirectory } from "./custom-providers.ts"; import { digest } from "../lib/digest.ts"; import type { Actor } from "../lib/actor.ts"; @@ -78,7 +79,7 @@ export function createSettingsService(db: DB["db"]) { throw new ProjectError(404, "project_not_found"); return rows; } - function parse(raw: unknown) { + async function parse(actor: Actor, raw: unknown) { const input = SettingsSave(structuredClone(raw)); if (input instanceof type.errors || Object.keys(input.fields).length === 0) throw new ProjectError(400, "invalid_settings_save"); @@ -89,7 +90,9 @@ export function createSettingsService(db: DB["db"]) { preference: unknown, }); const models = input.fields.models?.value; - const refused = models && invalidModelChoice(models); + const refused = + models && + invalidModelChoice(await modelDirectory(db, actor.tenantId), models); if (refused) throw new ProjectError(400, "unknown_model_choice", { choice: refused }); return input; @@ -120,7 +123,7 @@ export function createSettingsService(db: DB["db"]) { personal = false, ): Promise { if (!personal) await authorize(actor, projectId, "manage"); - const input = parse(raw); + const input = await parse(actor, raw); if ( personal && Object.keys(input.fields).some( diff --git a/apps/hub/src/workflows/build-packages.ts b/apps/hub/src/workflows/build-packages.ts index 0157e811..4867584c 100644 --- a/apps/hub/src/workflows/build-packages.ts +++ b/apps/hub/src/workflows/build-packages.ts @@ -3,7 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; import { nativePackageName } from "./package-name.ts"; -import type { RoleBinding } from "@corbits-builder/schemas/models"; +import type { RoleInference } from "./role-inference.ts"; import type { WorkflowRole } from "@corbits-builder/schemas/installed-workflows"; // The version follows the bundle bytes, so a rebuilt workflow publishes beside the old one instead of @@ -12,7 +12,7 @@ function contentVersion(base: string, bundle: string): string { return `${base}-sha-${createHash("sha256").update(bundle).digest("hex").slice(0, 8)}`; } -// Each package is an entry under entries/ built on its role's model binding. +// Each package is an entry under entries/ built on what its role's model binding resolved to. const WORKFLOW_PACKAGES: readonly { name: string; role: WorkflowRole }[] = [ ...(["coordinator", "writer", "reviewer", "coder", "designer"] as const).map( (role) => ({ @@ -26,7 +26,7 @@ const WORKFLOW_PACKAGES: readonly { name: string; role: WorkflowRole }[] = [ export async function buildWorkflowPackages( destination: string, base: string, - bindings: Record, + inference: Record, ) { if (!/^\d+\.\d+\.\d+$/.test(base)) throw new Error("invalid_package_version"); const output = resolve(destination); @@ -36,7 +36,7 @@ export async function buildWorkflowPackages( const result = await Bun.build({ define: { "process.env.BUILDER_ROLE_INFERENCE": JSON.stringify( - JSON.stringify(bindings[role]), + JSON.stringify(inference[role]), ), }, entrypoints: [join(import.meta.dir, "entries", name + ".ts")], diff --git a/apps/hub/src/workflows/install-for-tenant.ts b/apps/hub/src/workflows/install-for-tenant.ts index 0e28e4f3..ea5f6423 100644 --- a/apps/hub/src/workflows/install-for-tenant.ts +++ b/apps/hub/src/workflows/install-for-tenant.ts @@ -27,7 +27,9 @@ import { modelProvider, providerOfRow, workflowBindings, + type ModelDirectory, } from "../config/models.ts"; +import { modelDirectory } from "../settings/custom-providers.ts"; import { isOAuthModelPlugin, offeringQuirks, @@ -35,6 +37,7 @@ import { import { projectModelChoice } from "../projects/model-choice.ts"; import { ProjectError } from "../projects/errors.ts"; import { buildWorkflowPackages } from "./build-packages.ts"; +import { resolveRoleInference, type RoleInference } from "./role-inference.ts"; import type { Actor } from "../lib/actor.ts"; const PACKAGE_VERSION = "1.0.0"; @@ -49,9 +52,10 @@ type InstallForTenantPorts = { }; type Bindings = Record; +type Inference = Record; type Source = Pick; -// One build per set of role bindings: the binding is inlined, so it is part of the bundle. +// One build per set of resolved role inference: it is inlined, so it is part of the bundle. const packages = new Map< string, Promise< @@ -72,9 +76,12 @@ export function startFailed(): ProjectError { }); } -function providerMissing(source: Source): ProjectError { +function providerMissing( + directory: ModelDirectory, + source: Source, +): ProjectError { return new ProjectError(409, "inference_provider_missing", { - message: `Connect a ${modelProvider(source.provider)?.label ?? source.provider} model provider before starting a project.`, + message: `Connect a ${modelProvider(directory, source.provider)?.label ?? source.provider} model provider before starting a project.`, }); } @@ -93,33 +100,35 @@ function installedFrom(config: unknown): InstalledWorkflows | null { return parsed instanceof type.errors ? null : parsed; } -function builtPackages(bindings: Bindings) { - const key = JSON.stringify(bindings); +async function buildAndRead(key: string, inference: Inference) { + const directory = await mkdtemp(join(tmpdir(), "sb-hub-packages-")); + try { + const manifest = await buildWorkflowPackages( + directory, + PACKAGE_VERSION, + inference, + ); + return await Promise.all( + manifest.map(async (item) => ({ + ...item, + bytes: new Uint8Array( + await Bun.file(join(directory, item.file)).arrayBuffer(), + ), + })), + ); + } catch (error) { + packages.delete(key); + throw error; + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + +function builtPackages(inference: Inference) { + const key = JSON.stringify(inference); const cached = packages.get(key); if (cached) return cached; - const building = (async () => { - const directory = await mkdtemp(join(tmpdir(), "sb-hub-packages-")); - try { - const manifest = await buildWorkflowPackages( - directory, - PACKAGE_VERSION, - bindings, - ); - return await Promise.all( - manifest.map(async (item) => ({ - ...item, - bytes: new Uint8Array( - await Bun.file(join(directory, item.file)).arrayBuffer(), - ), - })), - ); - } catch (error) { - packages.delete(key); - throw error; - } finally { - await rm(directory, { recursive: true, force: true }); - } - })(); + const building = buildAndRead(key, inference); packages.set(key, building); return building; } @@ -142,23 +151,31 @@ type ProviderRow = VisibleOffering["provider"]; // What an offering must carry for its adapter: for a login, the host identity and the account the // login was made for; otherwise its provider entry's quirks. Null when the adapter takes none. -async function wantedQuirks(db: DB["db"], row: ProviderRow) { +async function wantedQuirks( + db: DB["db"], + directory: ModelDirectory, + row: ProviderRow, +) { if (isOAuthModelPlugin(row.plugin)) return offeringQuirks( row.plugin, await credentialMetadata(db, row.credentialId), ); - return providerOfRow(row)?.quirks ?? null; + return providerOfRow(directory, row)?.quirks ?? null; } -function wantedCapabilities(row: ProviderRow): readonly string[] { - return providerOfRow(row)?.capabilities ?? []; +function wantedCapabilities( + directory: ModelDirectory, + row: ProviderRow, +): readonly string[] | undefined { + return providerOfRow(directory, row)?.capabilities; } function declaresCapabilities( offering: VisibleOffering["offering"], - wanted: readonly string[], + wanted: readonly string[] | undefined, ) { + if (!wanted) return true; return wanted.every((capability) => offering.capabilities.some((have) => have === capability), ); @@ -168,6 +185,7 @@ function declaresCapabilities( // `inference.source::` never matches at deploy time. async function servesSource( db: DB["db"], + directory: ModelDirectory, row: VisibleOffering, source: Source, ): Promise { @@ -175,13 +193,16 @@ async function servesSource( row.offering.disabled || row.model.disabled || row.provider.disabled || - providerOfRow(row.provider)?.id !== source.provider || + providerOfRow(directory, row.provider)?.id !== source.provider || row.model.canonicalName !== source.model ) return false; - const quirks = await wantedQuirks(db, row.provider); + const quirks = await wantedQuirks(db, directory, row.provider); return ( - declaresCapabilities(row.offering, wantedCapabilities(row.provider)) && + declaresCapabilities( + row.offering, + wantedCapabilities(directory, row.provider), + ) && (quirks === null || JSON.stringify(row.offering.quirks) === JSON.stringify(quirks)) ); @@ -190,32 +211,35 @@ async function servesSource( /** The visible catalog offering a role bound to `source` runs on, once one is minted. */ export async function servingOffering( db: DB["db"], + directory: ModelDirectory, tenantId: string, source: Source, ) { for (const row of await listVisibleOfferings(db, tenantId)) - if (await servesSource(db, row, source)) return row.offering; + if (await servesSource(db, directory, row, source)) return row.offering; return undefined; } async function offeringId( db: DB["db"], + directory: ModelDirectory, tenantId: string, source: Source, ): Promise { - const serving = await servingOffering(db, tenantId, source); + const serving = await servingOffering(db, directory, tenantId, source); if (serving) return serving.id; // A project is a child tenant; its provider lives up the tree where the // person connected it. The model and offering are minted beside that // provider so every descendant resolves them. const visible = (await listVisibleProviders(db, tenantId)).find( (entry) => - providerOfRow(entry.row)?.id === source.provider && !entry.row.disabled, + providerOfRow(directory, entry.row)?.id === source.provider && + !entry.row.disabled, ); - if (!visible) throw providerMissing(source); + if (!visible) throw providerMissing(directory, source); const providerId = visible.row.id; - const quirks = await wantedQuirks(db, visible.row); - const capabilities = [...wantedCapabilities(visible.row)]; + const quirks = await wantedQuirks(db, directory, visible.row); + const capabilities = wantedCapabilities(directory, visible.row); tenantId = visible.origin.tenantId; const [named] = await db .select({ id: model.id }) @@ -259,7 +283,7 @@ async function offeringId( providerId, priority: 0, quirks, - capabilities, + ...(capabilities ? { capabilities: [...capabilities] } : {}), }) .onConflictDoNothing() .returning({ id: modelOffering.id }); @@ -277,9 +301,14 @@ async function offeringId( if (!raced) throw startFailed(); // The offering already existed for an earlier login or before its capabilities were declared; // it now names this one's account and carries the provider's capabilities. + const mutableCapabilities = capabilities ? [...capabilities] : undefined; await db .update(modelOffering) - .set(quirks === null ? { capabilities } : { quirks, capabilities }) + .set( + quirks === null + ? { capabilities: mutableCapabilities } + : { quirks, capabilities: mutableCapabilities }, + ) .where(eq(modelOffering.id, raced.id)); return raced.id; } @@ -296,6 +325,7 @@ const ROLES = [ /** A stored descriptor is current only while it pins this build's packages and, for each role, an offering serving its source. */ async function isCurrent( db: DB["db"], + directory: ModelDirectory, tenantId: string, installed: InstalledWorkflows, built: Built, @@ -315,7 +345,8 @@ async function isCurrent( const row = visible.find( (entry) => entry.offering.id === installed[role].defaultSourceOfferingId, ); - if (!row || !(await servesSource(db, row, bindings[role]))) return false; + if (!row || !(await servesSource(db, directory, row, bindings[role]))) + return false; } return true; } @@ -388,14 +419,30 @@ export async function installForTenant( .select({ config: tenant.config }) .from(tenant) .where(eq(tenant.id, actor.tenantId)); + const directory = await modelDirectory(ports.db, actor.tenantId); const bindings = workflowBindings( - await projectModelChoice(ports.db, actor, projectId), + directory, + await projectModelChoice(ports.db, actor, projectId, directory), + ); + const built = await builtPackages( + Object.fromEntries( + ROLES.map((role) => [ + role, + resolveRoleInference(directory, role, bindings[role]), + ]), + ) as Inference, ); - const built = await builtPackages(bindings); const existing = installedFrom(row?.config); if ( existing && - (await isCurrent(ports.db, actor.tenantId, existing, built, bindings)) + (await isCurrent( + ports.db, + directory, + actor.tenantId, + existing, + built, + bindings, + )) ) return existing; const offerings = new Map(); @@ -404,7 +451,7 @@ export async function installForTenant( if (!offerings.has(key)) offerings.set( key, - await offeringId(ports.db, actor.tenantId, bindings[role]), + await offeringId(ports.db, directory, actor.tenantId, bindings[role]), ); } const offeringFor = (role: WorkflowRole) => @@ -472,7 +519,14 @@ export async function installForTenant( const stored = installedFrom(current.config); if ( stored && - (await isCurrent(ports.db, actor.tenantId, stored, built, bindings)) + (await isCurrent( + ports.db, + directory, + actor.tenantId, + stored, + built, + bindings, + )) ) return; const config = diff --git a/apps/hub/src/workflows/packaged.test.ts b/apps/hub/src/workflows/packaged.test.ts index a37ccc74..3935f513 100644 --- a/apps/hub/src/workflows/packaged.test.ts +++ b/apps/hub/src/workflows/packaged.test.ts @@ -1,8 +1,18 @@ import { expect, test } from "bun:test"; import { NativeSpecialistDeployment } from "@corbits-builder/schemas/native-specialist"; -import { workflowBindings } from "../config/models.ts"; +import { MODEL_PROVIDERS, workflowBindings } from "../config/models.ts"; +import { resolveRoleInference, type RoleInference } from "./role-inference.ts"; +import type { WorkflowRole } from "@corbits-builder/schemas/installed-workflows"; -const bindings = workflowBindings({ profile: "anthropic_default" }); +const bindings = workflowBindings(MODEL_PROVIDERS, { + profile: "anthropic_default", +}); +const inference = Object.fromEntries( + Object.entries(bindings).map(([role, binding]) => [ + role, + resolveRoleInference(MODEL_PROVIDERS, role as WorkflowRole, binding), + ]), +) as Record; test("native specialist accepts installed tarballs only with an exact package pin", () => { const deployment: NativeSpecialistDeployment = { @@ -23,7 +33,7 @@ test("native specialist accepts installed tarballs only with an exact package pi }); test("production entries expose concrete native workflows without callback credentials", async () => { - process.env.BUILDER_ROLE_INFERENCE = JSON.stringify(bindings.coordinator); + process.env.BUILDER_ROLE_INFERENCE = JSON.stringify(inference.coordinator); const coordinator = (await import("./entries/coordinator.ts")).default; const writer = (await import("./entries/writer.ts")).default; const reviewer = (await import("./entries/reviewer.ts")).default; @@ -82,7 +92,7 @@ test("built npm tarballs contain importable production workflow metadata and def join(tmpdir(), "sb-packaged-definition-test-"), ); try { - const manifest = await buildWorkflowPackages(directory, "1.0.0", bindings); + const manifest = await buildWorkflowPackages(directory, "1.0.0", inference); expect(manifest).toHaveLength(6); for (const metadata of manifest) { const extracted = join(directory, metadata.assetName); diff --git a/apps/hub/src/workflows/role-inference.ts b/apps/hub/src/workflows/role-inference.ts index a283be28..d2e7b34b 100644 --- a/apps/hub/src/workflows/role-inference.ts +++ b/apps/hub/src/workflows/role-inference.ts @@ -1,32 +1,46 @@ -import { RoleBinding } from "@corbits-builder/schemas/models"; +import { type } from "arktype"; +import { Effort, type RoleBinding } from "@corbits-builder/schemas/models"; import type { WorkflowRole } from "@corbits-builder/schemas/installed-workflows"; -import { limitsRole, modelProvider } from "../config/models.ts"; import { - modelEfforts, - requireContextWindow, - requireModelRole, -} from "../inference/model-limits.ts"; + bindingLimits, + limitsRole, + modelProvider, + type ModelDirectory, +} from "../config/models.ts"; -// build-packages.ts inlines the binding the project's profile gave this role, so the frozen -// definition names it; an entry imported directly reads the one its importer set. -const INLINED = process.env.BUILDER_ROLE_INFERENCE; +export const RoleInference = type({ + "+": "reject", + source: { "+": "reject", provider: "string > 0", model: "string > 0" }, + contextWindow: "number.integer > 0", + parameters: { + "+": "reject", + "effort?": Effort, + maxTokens: "number.integer > 0", + "temperature?": "number >= 0", + }, +}); +export type RoleInference = typeof RoleInference.infer; -/** The model source, its window and the per-call parameters one role's package runs with. */ -export function roleInference(role: WorkflowRole) { - if (!INLINED) throw new Error(`role_binding_required:${role}`); - const bound = RoleBinding.assert(JSON.parse(INLINED)); - const plugin = modelProvider(bound.provider)?.plugin; +/** + * The model source, its window and the per-call parameters one role's package runs with. Resolved + * on the hub, where a workspace's custom models are known. + */ +export function resolveRoleInference( + directory: ModelDirectory, + role: WorkflowRole, + bound: RoleBinding, +): RoleInference { + const plugin = modelProvider(directory, bound.provider)?.plugin; if (!plugin) throw new Error(`model_provider_unknown:${bound.provider}`); - const budget = requireModelRole( - bound.provider, - bound.model, - limitsRole(role), - ); + const limits = bindingLimits(directory, bound.provider, bound.model); + if (!limits) + throw new Error(`model_limits_required:${bound.provider}:${bound.model}`); + const budget = limits.roles[limitsRole(role)]; return { source: { provider: plugin, model: bound.model }, - contextWindow: requireContextWindow(bound.provider, bound.model), + contextWindow: limits.contextWindow, parameters: { - ...(modelEfforts(bound.provider, bound.model).includes(bound.effort) + ...(limits.efforts.includes(bound.effort) ? { effort: bound.effort } : {}), maxTokens: budget.maxOutputTokens, @@ -36,3 +50,11 @@ export function roleInference(role: WorkflowRole) { }, }; } + +// build-packages.ts inlines what the project's profile resolved for this role, so the frozen +// definition carries it; an entry imported directly reads the one its importer set. +export function roleInference(role: WorkflowRole): RoleInference { + const inlined = process.env.BUILDER_ROLE_INFERENCE; + if (!inlined) throw new Error(`role_inference_required:${role}`); + return RoleInference.assert(JSON.parse(inlined)); +} diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts index 181f313a..db60e02b 100644 --- a/apps/web/src/config.ts +++ b/apps/web/src/config.ts @@ -864,6 +864,69 @@ export const wording = { provider: "Provider", baseUrl: "Base URL", key: "API key", + keyless: "Keyless", + keylessAbout: "About Keyless", + keylessHint: `For servers that need no API key; ${productName} sends a placeholder.`, + custom: { + tile: "Custom endpoint", + tileHint: "Your own server, on the API it speaks.", + addTitle: "Add a custom endpoint", + addLead: "Connect your own server. It is saved once the test passes.", + name: "Name", + api: "API type", + apiLead: "Pick the API your server speaks.", + apiPaths: { + "openai-compatible": "/v1/chat/completions", + "openai-responses": "/v1/responses", + anthropic: "/v1/messages", + }, + apiTips: { + "openai-compatible": + "OpenAI's chat format. vLLM, Ollama, LM Studio, llama.cpp and most hosted servers use it.", + "openai-responses": + "OpenAI's newer Responses format. OpenAI and a few servers that copy it use it.", + anthropic: + "Anthropic's Messages format. Anthropic and servers that copy it use it.", + }, + tips: { + name: `What this server is called in ${productName}.`, + api: "The request format your server understands.", + baseUrl: "The address requests go to, usually ending in /v1.", + key: "The key your server asks for. Kept encrypted and never shown again.", + modelId: "The exact model name your server expects.", + modelName: "How the model is shown. Empty shows its model ID.", + contextWindow: "How many tokens the model can read at once.", + maxOutputTokens: + "The most tokens it writes in one reply. Below the context window.", + }, + about: (label: string) => `About ${label}`, + models: "Models", + modelCount: (count: number) => + count === 1 ? "1 model" : `${count} models`, + duplicateIds: "Each model needs its own model ID.", + modelId: "Model ID", + modelName: "Name", + optional: "Optional", + contextWindow: "Context window", + maxOutputTokens: "Output cap", + addModel: "+ Add model", + removeModel: (id: string) => `Remove ${id}`, + keyAgain: "A new API type or base URL needs its key again, or Keyless.", + editTitle: (label: string) => `Edit ${label}`, + editLead: + "Change its name or connection. A new API type, base URL or key is tested before it is saved.", + options: (label: string) => `Options for ${label}`, + edit: "Edit", + remove: "Remove…", + confirmRemove: "Yes, remove it", + back: "Back", + save: "Save", + saving: "Saving…", + savedState: "Saved", + failedState: "Not saved", + saved: (label: string) => `${label} saved`, + removed: (label: string) => `${label} removed`, + }, test: "Test connection", testing: "Testing…", saved: (label: string) => `${label} connected`, diff --git a/apps/web/src/pages/custom-providers.tsx b/apps/web/src/pages/custom-providers.tsx new file mode 100644 index 00000000..14721ee1 --- /dev/null +++ b/apps/web/src/pages/custom-providers.tsx @@ -0,0 +1,1010 @@ +/** + * A person's own endpoints, in the row language of Settings: adding and editing one's connection + * in a dialog of rows, and its models under its row in Providers. + * + * The connection is a name, the API it speaks, a base URL, and a key or Keyless. The hub keeps the + * key on the base URL it was entered for, so a new API type, base URL or key is a new connection + * and is tested before it is saved, like any other key; a keyless server is tested with no key at + * all. The hub needs one model to add a provider, so the add dialog asks for the first; the rest + * are managed under the row. The dialog shell and card picker here are the add dialog's too. + */ +import { wording } from "../config.ts"; +import { Button as UiButton, Switch } from "@corbits/react-ui"; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@corbits/react-ui/ui/dialog"; +import { + Menu, + MenuContent, + MenuItem, + MenuSeparator, + MenuTrigger, +} from "@corbits/react-ui/ui/menu"; +import { InfoTooltip } from "@corbits/react-ui/ui/tooltip"; +import * as Tooltip from "@radix-ui/react-tooltip"; +import { useQueryClient } from "@tanstack/react-query"; +import { ChevronDown, Ellipsis, X } from "lucide-react"; +import { useRef, useState, type KeyboardEvent, type ReactNode } from "react"; +import { toast } from "sonner"; +import type { + CustomApi, + CustomModel, + CustomProviderCreate, + ModelCatalog, + ModelProvider, +} from "@corbits-builder/schemas/models"; +import { Banner, Button, QuietState } from "../components.jsx"; +import { keys } from "../queries/keys.ts"; +import { productClient } from "../api/transport.ts"; +import { workspaceTenantId } from "../api/auth.ts"; +import { ProviderLogo } from "./provider-logos.tsx"; + +const copy = wording.providers; +const tips = copy.custom.tips; + +type ModelDraft = { + id: string; + label: string; + contextWindow: string; + maxOutputTokens: string; +}; + +type CustomDraft = { + name: string; + api: CustomApi; + baseURL: string; + secret: string; + keyless: boolean; + model: ModelDraft; +}; + +type Apis = ModelCatalog["customApis"]; + +type SaveState = "saving" | "saved" | "failed"; + +// A model listed under its provider; a fixed one is saved, so its id is no longer edited. +type ListedModel = ModelDraft & { fixed: boolean }; + +const BLANK_MODEL: ModelDraft = { + id: "", + label: "", + contextWindow: "", + maxOutputTokens: "", +}; + +const BLANK_DRAFT: CustomDraft = { + name: "", + api: "openai-compatible", + baseURL: "", + secret: "", + keyless: false, + model: BLANK_MODEL, +}; + +// The fields of a model after its id, each a column under the list's heads. +const MODEL_FIELDS = [ + { key: "label", label: copy.custom.modelName, tip: tips.modelName }, + { + key: "contextWindow", + label: copy.custom.contextWindow, + tip: tips.contextWindow, + }, + { + key: "maxOutputTokens", + label: copy.custom.maxOutputTokens, + tip: tips.maxOutputTokens, + }, +] as const satisfies readonly { + key: keyof ModelDraft; + label: string; + tip: string; +}[]; + +const SUBMIT_LABELS = { + test: { idle: copy.test, busy: copy.testing }, + save: { idle: copy.custom.save, busy: copy.custom.saving }, +} as const; + +const SAVE_STATES = { + saving: { tone: "loading", label: copy.custom.saving }, + saved: { tone: "success", label: copy.custom.savedState }, + failed: { tone: "error", label: copy.custom.failedState }, +} as const satisfies Record; + +const STEP: Partial> = { + ArrowRight: 1, + ArrowDown: 1, + ArrowLeft: -1, + ArrowUp: -1, +}; + +export function message(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause); +} + +/** Title, lead, the fields, the last error, and Cancel or Back beside the dialog's action. */ +export function ProviderDialog(props: { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description: string; + error: string | null; + busy: boolean; + ready: boolean; + /** Whether submitting tests a connection first. */ + tests: boolean; + onSubmit: () => void; + /** Shown in place of Cancel when the dialog was reached from another view of itself. */ + onBack?: (() => void) | undefined; + /** Fields laid out as Settings rows rather than stacked. */ + rows?: boolean; + /** Opens with focus on the dialog itself, so no field opens with its text selected. */ + focusDialog?: boolean; + children: ReactNode; +}) { + const { busy, error, onBack, onOpenChange } = props; + return ( + + { + event.preventDefault(); + (event.currentTarget as HTMLElement).focus(); + }, + } + : {})} + > + + {props.title} + {props.description} + +
{ + event.preventDefault(); + props.onSubmit(); + }} + > + + {props.children} + {error ? : null} + + + + + +
+
+
+ ); +} + +/** + * One card per choice, as a radio group: arrow keys move the choice, Tab leaves the group. A + * choice with a `tip` explains itself on hover or focus. + */ +export function ProviderPicker({ + providers, + chosenId, + disabled, + labelledBy, + className = "provider-cards", + onChoose, +}: { + providers: readonly (Pick & { + tip?: string; + })[]; + chosenId: string | undefined; + disabled: boolean; + labelledBy: string; + className?: string; + onChoose: (id: string) => void; +}) { + const cards = useRef<(HTMLButtonElement | null)[]>([]); + const move = (event: KeyboardEvent, index: number) => { + const step = STEP[event.key]; + let target: number | undefined; + if (event.key === "Home") target = 0; + else if (event.key === "End") target = providers.length - 1; + else if (step !== undefined) + target = (index + step + providers.length) % providers.length; + const provider = target === undefined ? undefined : providers[target]; + if (target === undefined || !provider) return; + event.preventDefault(); + onChoose(provider.id); + cards.current[target]?.focus(); + }; + return ( +
+ {providers.map((provider, index) => { + const checked = provider.id === chosenId; + const card = ( + + ); + return provider.tip ? ( + + {card} + + ) : ( + card + ); + })} +
+ ); +} + +/** The library's info-tooltip surface around any trigger; the library only offers it on its glyph. */ +function Tip({ label, children }: { label: string; children: ReactNode }) { + return ( + + + {children} + + + {label} + + + + + + ); +} + +/** A Settings row: its label with a tooltip, an optional hint, and its control on the right. */ +function FieldRow({ + label, + tip, + htmlFor, + labelId, + hint, + children, +}: { + label: string; + tip: string; + htmlFor?: string; + /** Names a group the row heads, when it labels no single control. */ + labelId?: string; + hint?: string | null; + children?: ReactNode; +}) { + return ( +
+
+ + {htmlFor ? ( + + ) : ( + + )} + + + {hint ? {hint} : null} +
+ {children} +
+ ); +} + +function keylessOf(provider: ModelProvider): boolean { + return provider.connections.some((connection) => connection.keyless); +} + +function draftOf(provider: ModelProvider): CustomDraft { + return { + name: provider.label, + api: provider.plugin as CustomApi, + baseURL: provider.baseURL, + secret: "", + keyless: keylessOf(provider), + model: BLANK_MODEL, + }; +} + +function modelDraftOf(model: ModelProvider["models"][number]): ModelDraft { + const label = model.label; + return { + id: model.id, + // An unnamed custom model drafts with an empty label; it shows by its id. + label: label !== undefined ? label : "", + contextWindow: "contextWindow" in model ? String(model.contextWindow) : "", + maxOutputTokens: + "maxOutputTokens" in model ? String(model.maxOutputTokens) : "", + }; +} + +function tokens(value: string): number | null { + const count = Number(value); + return Number.isInteger(count) && count > 0 ? count : null; +} + +function duplicateIds(models: readonly ModelDraft[]): boolean { + const ids = models.map((model) => model.id.trim()).filter(Boolean); + return new Set(ids).size !== ids.length; +} + +/** The models drafted, once every id is set and distinct and each output cap is below its window. */ +function parsedModels(models: readonly ModelDraft[]): CustomModel[] | null { + if (models.length === 0 || duplicateIds(models)) return null; + const parsed: CustomModel[] = []; + for (const model of models) { + const id = model.id.trim(); + const label = model.label.trim(); + const contextWindow = tokens(model.contextWindow); + const maxOutputTokens = tokens(model.maxOutputTokens); + if (!id || !contextWindow || !maxOutputTokens) return null; + if (maxOutputTokens >= contextWindow) return null; + parsed.push({ + id, + ...(label ? { label } : {}), + contextWindow, + maxOutputTokens, + }); + } + return parsed; +} + +/** Whether saving makes a new connection: always when adding, else on a new API, URL or key. */ +function reconnects(draft: CustomDraft, original?: ModelProvider): boolean { + return ( + !original || + draft.api !== original.plugin || + draft.baseURL.trim() !== original.baseURL || + draft.keyless !== keylessOf(original) || + draft.secret.trim().length > 0 + ); +} + +function endpointOf(draft: CustomDraft): CustomProviderCreate["connection"] { + const endpoint = { api: draft.api, baseURL: draft.baseURL.trim() }; + return draft.keyless + ? { ...endpoint, keyless: true } + : { ...endpoint, secret: draft.secret.trim() }; +} + +async function testConnection(draft: CustomDraft): Promise { + const { api, ...connection } = endpointOf(draft); + const { status } = await productClient().testProvider( + await workspaceTenantId(), + { plugin: api, ...connection }, + ); + if (status !== "connected") throw new Error(copy.failed[status]); +} + +/** + * One custom provider's connection form: its rows, whether it can be submitted, whether + * submitting tests a new connection first, and `save`, which throws a failed test's reason. + */ +export function useCustomProviderForm(apis: Apis, original?: ModelProvider) { + const [draft, setDraft] = useState(() => + original ? draftOf(original) : BLANK_DRAFT, + ); + const tests = reconnects(draft, original); + const keyed = draft.keyless || draft.secret.trim().length > 0; + return { + name: draft.name.trim(), + tests, + ready: + draft.name.trim().length > 0 && + draft.baseURL.trim().length > 0 && + (!tests || keyed) && + (original !== undefined || parsedModels([draft.model]) !== null), + reset: () => setDraft(BLANK_DRAFT), + rows: (disabled: boolean) => ( + <> + + {original ? null : ( + setDraft({ ...draft, model })} + /> + )} + + ), + save: async (): Promise => { + if (tests) await testConnection(draft); + const tenantId = await workspaceTenantId(); + const client = productClient(); + const name = draft.name.trim(); + if (!original) { + await client.createCustomProvider(tenantId, { + name, + models: parsedModels([draft.model])!, + connection: endpointOf(draft), + }); + return; + } + await client.updateCustomProvider(tenantId, original.id, { + name, + models: parsedModels(original.models.map(modelDraftOf))!, + ...(tests ? { connection: endpointOf(draft) } : {}), + }); + }, + }; +} + +function ConnectionRows({ + draft, + apis, + disabled, + original, + onChange, +}: { + draft: CustomDraft; + apis: Apis; + disabled: boolean; + original: ModelProvider | undefined; + onChange: (draft: CustomDraft) => void; +}) { + const keyAgain = + original !== undefined && + (draft.api !== original.plugin || + draft.baseURL.trim() !== original.baseURL); + return ( + <> + + onChange({ ...draft, name: event.target.value })} + /> + + + ({ + ...api, + hint: copy.custom.apiPaths[api.id].replaceAll("/", "/\u200b"), + tip: copy.custom.apiTips[api.id], + }))} + chosenId={draft.api} + disabled={disabled} + labelledBy="custom-api-label" + className="api-options" + onChoose={(api) => onChange({ ...draft, api: api as CustomApi })} + /> + + + onChange({ ...draft, baseURL: event.target.value }) + } + /> + + {draft.keyless ? null : ( + + + onChange({ ...draft, secret: event.target.value }) + } + /> + + )} + + + onChange({ ...draft, keyless, secret: "" }) + } + /> + + + ); +} + +/** The column heads of a model list, each with its tooltip, and a save state when there is one. */ +function ModelHeader({ + duplicate, + removable = false, + children, +}: { + duplicate: boolean; + /** Whether the rows under it end in a remove button, which the heads leave room for. */ + removable?: boolean; + children?: ReactNode; +}) { + return ( +
+
+ + {copy.custom.modelId} + + + {duplicate ? ( + {copy.custom.duplicateIds} + ) : ( + children + )} +
+ + {MODEL_FIELDS.map((field) => ( + + {field.label} + + + ))} + {removable ? ( + +
+ ); +} + +/** One model as a row of quiet fields; its id is a field until it is saved. */ +function ModelRow({ + model, + editableId, + disabled, + onChange, + onCommit, + children, +}: { + model: ModelDraft; + editableId: boolean; + disabled: boolean; + onChange: (model: ModelDraft) => void; + onCommit?: () => void; + children?: ReactNode; +}) { + const blur = onCommit ? { onBlur: onCommit } : {}; + return ( +
+
+ {editableId ? ( + onChange({ ...model, id: event.target.value })} + {...blur} + /> + ) : ( + {model.id} + )} +
+ + {MODEL_FIELDS.map((field) => ( + + onChange({ ...model, [field.key]: event.target.value }) + } + {...blur} + /> + ))} + {children} + +
+ ); +} + +/** The one model a provider is added with, laid out as the list under its row will show it. */ +function FirstModelRows({ + model, + disabled, + onChange, +}: { + model: ModelDraft; + disabled: boolean; + onChange: (model: ModelDraft) => void; +}) { + return ( + <> + + + + ); +} + +/** + * A custom provider's row in Providers: its options menu, and its models listed under it on + * request, each edited in place and saved once it is complete. + */ +export function CustomProviderRow({ + provider, + apis, + onChanged, + onError, +}: { + provider: ModelProvider; + apis: Apis; + onChanged: () => void; + onError: (message: string | null) => void; +}) { + const [open, setOpen] = useState(false); + const listId = `custom-models-${provider.id}`; + return ( + <> +
+
+ {provider.label} + {provider.hint} +
+ + + + +
+ {open ? ( +
+ +
+ ) : null} + + ); +} + +function CustomProviderModels({ + provider, + onChanged, +}: { + provider: ModelProvider; + onChanged: () => void; +}) { + const queryClient = useQueryClient(); + const [models, setModels] = useState(() => + provider.models.map((model) => ({ ...modelDraftOf(model), fixed: true })), + ); + const [saveState, setSaveState] = useState(null); + const [error, setError] = useState(null); + const saved = useRef(JSON.stringify(parsedModels(models))); + + // Saves once the list is complete and differs from what the hub holds; a half-typed model waits. + const commit = async (next: readonly ListedModel[]) => { + const parsed = parsedModels(next); + const snapshot = JSON.stringify(parsed); + if (!parsed || snapshot === saved.current) return; + setSaveState("saving"); + setError(null); + try { + await productClient().updateCustomProvider( + await workspaceTenantId(), + provider.id, + { name: provider.label, models: parsed }, + ); + saved.current = snapshot; + setModels((current) => + current.map((model) => ({ + ...model, + fixed: model.fixed || parsed.some(({ id }) => id === model.id.trim()), + })), + ); + setSaveState("saved"); + await queryClient.invalidateQueries({ queryKey: keys.modelCatalog }); + onChanged(); + } catch (cause) { + setSaveState("failed"); + setError(message(cause)); + } + }; + + return ( + <> + + {saveState ? ( + + {SAVE_STATES[saveState].label} + + ) : null} + + {models.map((model, index) => ( + + setModels( + models.map((entry, at) => + at === index ? { ...next, fixed: model.fixed } : entry, + ), + ) + } + onCommit={() => void commit(models)} + > + { + const rest = models.filter((_, at) => at !== index); + setModels(rest); + void commit(rest); + }} + > + + + ))} +
+ +
+ {error ? : null} + + ); +} + +/** One custom provider's options menu: edit its connection, or remove it. */ +function CustomProviderActions({ + provider, + apis, + onChanged, + onError, +}: { + provider: ModelProvider; + apis: Apis; + onChanged: () => void; + onError: (message: string | null) => void; +}) { + const queryClient = useQueryClient(); + const [editing, setEditing] = useState(false); + const [removing, setRemoving] = useState(false); + // Removing takes two clicks, both in the menu, as deleting a project does. + const [confirming, setConfirming] = useState(false); + + const remove = async () => { + setRemoving(true); + onError(null); + try { + await productClient().removeCustomProvider( + await workspaceTenantId(), + provider.id, + ); + toast.success(copy.custom.removed(provider.label)); + await queryClient.invalidateQueries({ queryKey: keys.modelCatalog }); + onChanged(); + } catch (cause) { + onError(message(cause)); + } finally { + setRemoving(false); + } + }; + + return ( + <> + { + if (!open) setConfirming(false); + }} + > + + + + + + setEditing(true)}> + {copy.custom.edit} + + + {confirming ? ( + void remove()} + > + {copy.custom.confirmRemove} + + ) : ( + { + event.preventDefault(); + setConfirming(true); + }} + > + {copy.custom.remove} + + )} + + + {editing ? ( + setEditing(false)} + onSaved={onChanged} + /> + ) : null} + + ); +} + +function EditCustomProviderDialog({ + provider, + apis, + onClose, + onSaved, +}: { + provider: ModelProvider; + apis: Apis; + onClose: () => void; + onSaved: () => void; +}) { + const queryClient = useQueryClient(); + const form = useCustomProviderForm(apis, provider); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const save = async () => { + if (!form.ready || busy) return; + setBusy(true); + setError(null); + try { + await form.save(); + toast.success(copy.custom.saved(form.name)); + await queryClient.invalidateQueries({ queryKey: keys.modelCatalog }); + onSaved(); + onClose(); + } catch (cause) { + setError(message(cause)); + } finally { + setBusy(false); + } + }; + + return ( + (open || busy ? undefined : onClose())} + title={copy.custom.editTitle(provider.label)} + description={copy.custom.editLead} + error={error} + busy={busy} + ready={form.ready} + tests={form.tests} + onSubmit={() => void save()} + rows + focusDialog + > + {form.rows(busy)} + + ); +} diff --git a/apps/web/src/pages/model-setup.tsx b/apps/web/src/pages/model-setup.tsx index 6451a174..8899bbc9 100644 --- a/apps/web/src/pages/model-setup.tsx +++ b/apps/web/src/pages/model-setup.tsx @@ -38,7 +38,7 @@ export function clearModelSetupDeferral(): void { /** No connection, the workspace's or one it inherits, holds a usable key. An empty read is this case. */ export function needsModelSetup( providers: readonly { - connections: readonly { credentialSet: boolean; status: string }[]; + connections: readonly { credentialSet: boolean; status?: string }[]; }[], ): boolean { return !providers.some((provider) => diff --git a/apps/web/src/pages/profiles.tsx b/apps/web/src/pages/profiles.tsx index 50e6e41d..6b745221 100644 --- a/apps/web/src/pages/profiles.tsx +++ b/apps/web/src/pages/profiles.tsx @@ -57,8 +57,12 @@ export function connected(catalog: ModelCatalog, provider: string): boolean { return ( catalog.providers .find((entry) => entry.id === provider) - ?.connections.some((connection) => connection.status === "connected") ?? - false + ?.connections.some( + (connection) => + connection.status === "connected" || + // A custom endpoint is never probed on read, so its key being set is what is known. + (connection.status === undefined && connection.credentialSet), + ) ?? false ); } @@ -434,7 +438,7 @@ function ProfileEditor({ {provider?.models.map((model) => ( ))} diff --git a/apps/web/src/pages/provider-logos.tsx b/apps/web/src/pages/provider-logos.tsx index 372dc5a9..7ef827af 100644 --- a/apps/web/src/pages/provider-logos.tsx +++ b/apps/web/src/pages/provider-logos.tsx @@ -13,6 +13,9 @@ const XAI = const MARKS: Readonly> = { openai: OPENAI, codex: OPENAI, + // A custom endpoint's API type, shown with the mark of the API it speaks. + "openai-compatible": OPENAI, + "openai-responses": OPENAI, anthropic: "M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z", google: diff --git a/apps/web/src/pages/providers.tsx b/apps/web/src/pages/providers.tsx index 14901bb9..bc64e9d8 100644 --- a/apps/web/src/pages/providers.tsx +++ b/apps/web/src/pages/providers.tsx @@ -4,24 +4,16 @@ * Which providers exist, what they are called and what they need comes from the hub's * config through `/settings/models`. A subscription provider is connected by logging in: the hub * runs the sign-in, this page opens its authorize URL and waits for the hub to finish. Adding an - * API-key provider is: pick it, paste its key (and base URL), test it with one real model-list - * call, and only a key that passes is saved. Keys live in form state until they are handed over, - * and nothing here renders a key or token back. + * API-key provider is: pick it, paste its key, test it with one real model-list call, and only a + * key that passes is saved. A custom endpoint is added from the same dialog and + * sits in the same list (custom-providers.tsx). Keys live in form state until they are handed + * over, and nothing here renders a key or token back. */ import { wording } from "../config.ts"; import { EmptyState, Input, Section } from "@corbits/react-ui"; -import { - Dialog, - DialogBody, - DialogContent, - DialogDescription, - DialogFieldLabel, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@corbits/react-ui/ui/dialog"; +import { DialogFieldLabel } from "@corbits/react-ui/ui/dialog"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useRef, useState, type KeyboardEvent } from "react"; +import { useState } from "react"; import { toast } from "sonner"; import type { ModelProvider, @@ -33,10 +25,19 @@ import { keys } from "../queries/keys.ts"; import { productClient } from "../api/transport.ts"; import { workspaceTenantId } from "../api/auth.ts"; import { setCredential } from "../api/credentials.ts"; -import { ProviderLogo } from "./provider-logos.tsx"; +import { + CustomProviderRow, + ProviderDialog, + ProviderPicker, + message, + useCustomProviderForm, +} from "./custom-providers.tsx"; const copy = wording.providers; +// The picker's tile for a person's own endpoint; no catalog provider has this id. +const CUSTOM_ENDPOINT = "custom-endpoint"; + const POLL_MS = 1000; // The hub's login holds the provider's loopback port for five minutes. const LOGIN_DEADLINE_MS = 5 * 60 * 1000; @@ -48,10 +49,6 @@ const STATUS_TONE = { unconfigured: "warning", } as const satisfies Record; -function message(cause: unknown): string { - return cause instanceof Error ? cause.message : String(cause); -} - export function useModelCatalog() { return useQuery({ queryKey: keys.modelCatalog, @@ -157,10 +154,12 @@ export function SubscriptionLogins({ export function ProvidersSection({ onChanged }: { onChanged: () => void }) { const catalog = useModelCatalog(); const [adding, setAdding] = useState(false); + const [error, setError] = useState(null); const providers = catalog.data?.providers ?? []; + const customApis = catalog.data?.customApis; const hasLogins = providers.some((provider) => provider.kind === "login"); const connected = providers - .filter((provider) => provider.kind === "api_key") + .filter((provider) => provider.kind !== "login") .flatMap((provider) => provider.connections.map((connection) => ({ provider, connection })), ); @@ -178,20 +177,37 @@ export function ProvidersSection({ onChanged }: { onChanged: () => void }) { } /> ) : null} - {connected.map(({ provider, connection }) => ( -
-
- {connection.name} - {connection.name === provider.label ? null : ( - {provider.label} - )} - {connection.note ? {connection.note} : null} + {connected.map(({ provider, connection }) => { + if (provider.kind === "custom") { + if (!customApis) return null; + return ( + + ); + } + return ( +
+
+ {connection.name} + {connection.name === provider.label ? null : ( + {provider.label} + )} + {connection.note ? {connection.note} : null} +
+ {connection.status ? ( + + {copy.status[connection.status]} + + ) : null}
- - {copy.status[connection.status]} - -
- ))} + ); + })} + {error ? : null} ); const addAction = @@ -266,39 +282,66 @@ export function AddProviderDialog({ const providers = (catalog.data?.providers ?? []).filter( (provider) => provider.kind === "api_key", ); + const customApis = catalog.data?.customApis; + const apis = customApis !== undefined ? customApis : []; const [chosenId, setChosenId] = useState(null); - const [baseUrl, setBaseUrl] = useState(""); const [secret, setSecret] = useState(""); + const customForm = useCustomProviderForm(apis); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const custom = providerId === undefined && chosenId === CUSTOM_ENDPOINT; const chosen: ModelProvider | undefined = providers.find((provider) => provider.id === (providerId ?? chosenId)) ?? providers[0]; - const ready = - chosen !== undefined && - secret.trim().length > 0 && - (!chosen.needsBaseUrl || baseUrl.trim().length > 0); + const keyReady = chosen !== undefined && secret.trim().length > 0; + const ready = custom ? customForm.ready : keyReady; + const picker = [ + ...providers, + ...(providerId === undefined && apis.length > 0 + ? [ + { + id: CUSTOM_ENDPOINT, + label: copy.custom.tile, + hint: copy.custom.tileHint, + }, + ] + : []), + ]; const close = (next: boolean) => { if (busy) return; if (!next) { setSecret(""); - setBaseUrl(""); + customForm.reset(); setError(null); } onOpenChange(next); }; + const saved = async (label: string) => { + setSecret(""); + customForm.reset(); + toast.success(copy.saved(label)); + await queryClient.invalidateQueries({ queryKey: keys.modelCatalog }); + onConnected(); + onOpenChange(false); + }; + const testAndSave = async () => { if (!ready || busy) return; setBusy(true); setError(null); try { + if (custom) { + await customForm.save(); + await saved(customForm.name); + return; + } + if (!keyReady) return; const tenantId = await workspaceTenantId(); - const endpoint = chosen.needsBaseUrl ? baseUrl.trim() : chosen.baseURL; const { status } = await productClient().testProvider(tenantId, { plugin: chosen.plugin, - baseURL: endpoint, + baseURL: chosen.baseURL, secret: secret.trim(), }); if (status !== "connected") { @@ -308,15 +351,10 @@ export function AddProviderDialog({ await setCredential({ plugin: chosen.plugin, secret: secret.trim(), - baseURL: endpoint, + baseURL: chosen.baseURL, label: chosen.label, }); - setSecret(""); - setBaseUrl(""); - toast.success(copy.saved(chosen.label)); - await queryClient.invalidateQueries({ queryKey: keys.modelCatalog }); - onConnected(); - onOpenChange(false); + await saved(chosen.label); } catch (cause) { setError(message(cause)); } finally { @@ -324,170 +362,73 @@ export function AddProviderDialog({ } }; - return ( - - - - - {providerId && chosen - ? copy.connectOne(chosen.label) - : copy.dialogTitle} - - - {providerId ? copy.keyLead : copy.dialogLead} - - -
{ - event.preventDefault(); - void testAndSave(); - }} - > - - {catalog.error ? ( - - ) : null} - {providerId ? null : ( -
- - {copy.provider} - - { - setChosenId(id); - setError(null); - }} - /> -
- )} - {chosen?.needsBaseUrl ? ( -
- - {copy.baseUrl} - - setBaseUrl(event.target.value)} - /> -
- ) : null} -
- - {copy.key} - - setSecret(event.target.value)} - /> -
- {error ? : null} -
- - - - -
-
-
- ); -} + let dialogTitle: string; + if (custom) dialogTitle = copy.custom.addTitle; + else if (providerId && chosen) dialogTitle = copy.connectOne(chosen.label); + else dialogTitle = copy.dialogTitle; -const STEP: Partial> = { - ArrowRight: 1, - ArrowDown: 1, - ArrowLeft: -1, - ArrowUp: -1, -}; + let dialogDescription: string; + if (custom) dialogDescription = copy.custom.addLead; + else if (providerId) dialogDescription = copy.keyLead; + else dialogDescription = copy.dialogLead; -/** One card per provider, as a radio group: arrow keys move the choice, Tab leaves the group. */ -function ProviderPicker({ - providers, - chosenId, - disabled, - onChoose, -}: { - providers: readonly ModelProvider[]; - chosenId: string | undefined; - disabled: boolean; - onChoose: (id: string) => void; -}) { - const cards = useRef<(HTMLButtonElement | null)[]>([]); - const move = (event: KeyboardEvent, index: number) => { - const step = STEP[event.key]; - const target = - event.key === "Home" - ? 0 - : event.key === "End" - ? providers.length - 1 - : step === undefined - ? undefined - : (index + step + providers.length) % providers.length; - const provider = target === undefined ? undefined : providers[target]; - if (target === undefined || !provider) return; - event.preventDefault(); - onChoose(provider.id); - cards.current[target]?.focus(); - }; return ( -
void testAndSave()} + onBack={ + custom + ? () => { + setChosenId(null); + setError(null); + } + : undefined + } + rows={custom} > - {providers.map((provider, index) => { - const checked = provider.id === chosenId; - return ( - - ); - })} -
+ /> +
+ )} + {custom ? ( + customForm.rows(busy) + ) : ( +
+ {copy.key} + setSecret(event.target.value)} + /> +
+ )} + ); } diff --git a/apps/web/src/pages/settings-layout.css b/apps/web/src/pages/settings-layout.css index 63bac83e..44f86153 100644 --- a/apps/web/src/pages/settings-layout.css +++ b/apps/web/src/pages/settings-layout.css @@ -73,7 +73,8 @@ gap: var(--space-1); } -.settings-page .row { +.settings-page .row, +.settings-rows .row { display: flex; align-items: center; justify-content: space-between; @@ -82,14 +83,16 @@ font-size: 14px; } -.settings-page .row .k { +.settings-page .row .k, +.settings-rows .row .k { display: flex; flex-direction: column; gap: 1px; min-width: 0; } -.settings-page .row .k b { +.settings-page .row .k b, +.settings-rows .row .k b { display: inline-flex; align-items: center; gap: var(--space-2); @@ -97,13 +100,15 @@ font-size: 13.5px; } -.settings-page .row .k span { +.settings-page .row .k span, +.settings-rows .row .k span { font-size: 12px; color: var(--muted-foreground); font-family: var(--font-mono); } -.settings-page .row .v { +.settings-page .row .v, +.settings-rows .row .v { font-family: var(--font-mono); font-size: 12px; color: var(--muted-foreground); @@ -116,7 +121,8 @@ max-width: min(420px, 100%); } -.settings-page input.field { +.settings-page input.field, +.settings-rows input.field { display: block; font-family: var(--font-mono); font-size: 12.5px; @@ -130,7 +136,8 @@ outline: none; } -.settings-page input.field:focus { +.settings-page input.field:focus, +.settings-rows input.field:focus { border-color: var(--primary); } @@ -160,11 +167,13 @@ padding: var(--space-4) 0 var(--space-12); } - .settings-page .row { + .settings-page .row, + .settings-rows .row { flex-wrap: wrap; } - .settings-page .row .v { + .settings-page .row .v, + .settings-rows .row .v { flex-shrink: 1; justify-content: flex-start; } @@ -174,7 +183,8 @@ } } -.settings-page .quiet-state { +.settings-page .quiet-state, +.settings-rows .quiet-state { display: inline-flex; align-items: center; gap: var(--space-2); @@ -206,6 +216,76 @@ } } +/* Settings rows in a dialog: the body's own gap would double the rows' spacing, and rows sit a + little closer so a whole form fits without scrolling. */ +.provider-form.settings-rows { + gap: 0; +} + +.provider-form.settings-rows .row { + padding: var(--space-2) 0; +} + +/* A custom endpoint's API types: three compact choices on one line, the path each one calls. */ +.api-options { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-2); + padding-bottom: var(--space-1); +} + +.api-options .provider-hint { + font-family: var(--font-mono); + font-size: 12px; +} + +/* A custom provider's models, listed under its row. */ +.custom-models { + padding-left: var(--space-4); +} + +.settings-page input.field.token-field, +.settings-rows input.field.token-field, +.token-field { + width: 104px; +} + +.token-head { + display: inline-flex; + align-items: center; + gap: var(--space-1); + font-family: var(--font-sans); +} + +.token-remove { + width: 2.25rem; + flex: none; +} + +.models-toggle { + display: inline-flex; + align-items: center; + gap: var(--space-1); +} + +@media (max-width: 560px) { + .settings-page input.field.token-field, + .settings-rows input.field.token-field, + .token-field { + width: 88px; + } + + .custom-models { + padding-left: 0; + } +} + +@media (max-width: 520px) { + .api-options { + grid-template-columns: minmax(0, 1fr); + } +} + .settings-page .about-subhead { margin: var(--space-2) 0 0; font-size: 13.5px; diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 31f1b9af..67ad9338 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1394,6 +1394,13 @@ select.quiet-control { padding: 4px; } +/* The form sits between the dialog's header and its edges, so the body scrolls and the footer stays. */ +.provider-dialog-form { + display: flex; + flex-direction: column; + min-height: 0; +} + .provider-field { display: flex; flex-direction: column; diff --git a/packages/client/src/client/settings.ts b/packages/client/src/client/settings.ts index 47baf836..37208b2c 100644 --- a/packages/client/src/client/settings.ts +++ b/packages/client/src/client/settings.ts @@ -8,6 +8,10 @@ function personalSettingsPath(tenantId: string) { return `${tenantPath(tenantId)}/settings/me`; } +function customProvidersPath(tenantId: string, providerId?: string) { + return `${tenantPath(tenantId)}/settings/custom-providers${providerId ? `/${encodeURIComponent(providerId)}` : ""}`; +} + export function buildSettingsMethods( deps: ClientDeps, ): Pick< @@ -17,6 +21,9 @@ export function buildSettingsMethods( | "preferenceRegistry" | "modelCatalog" | "testProvider" + | "createCustomProvider" + | "updateCustomProvider" + | "removeCustomProvider" | "personalSettings" | "savePersonalSettings" > { @@ -45,6 +52,20 @@ export function buildSettingsMethods( input, ); }, + createCustomProvider(tenantId, input, signal) { + return request(customProvidersPath(tenantId), signal, input); + }, + updateCustomProvider(tenantId, providerId, input, signal) { + return request(customProvidersPath(tenantId, providerId), signal, input); + }, + removeCustomProvider(tenantId, providerId, signal) { + return request( + customProvidersPath(tenantId, providerId), + signal, + null, + "DELETE", + ); + }, personalSettings(tenantId, signal) { return request(personalSettingsPath(tenantId), signal); }, diff --git a/packages/client/src/client/shared.ts b/packages/client/src/client/shared.ts index 11023e92..1ca2008c 100644 --- a/packages/client/src/client/shared.ts +++ b/packages/client/src/client/shared.ts @@ -9,6 +9,9 @@ import type { DiscoveredModels } from "@corbits-builder/schemas/catalog"; import type { SettingsProviders } from "@corbits-builder/schemas/settings"; import type { PreferenceRegistry } from "@corbits-builder/schemas/preferences"; import type { + CustomProviderCreate, + CustomProviderReceipt, + CustomProviderUpdate, ModelCatalog, ProviderTest, ProviderTestResult, @@ -202,6 +205,24 @@ export type ProductApiClient = Readonly<{ input: ProviderTest, signal?: AbortSignal, ): Promise; + /** Adds a custom provider: its connection, key and models at once. The key is never returned. */ + createCustomProvider( + tenantId: string, + input: CustomProviderCreate, + signal?: AbortSignal, + ): Promise; + /** Renames a custom provider and replaces its models; a given connection replaces its old one. */ + updateCustomProvider( + tenantId: string, + providerId: string, + input: CustomProviderUpdate, + signal?: AbortSignal, + ): Promise; + removeCustomProvider( + tenantId: string, + providerId: string, + signal?: AbortSignal, + ): Promise; personalSettings( tenantId: string, signal?: AbortSignal, @@ -509,7 +530,7 @@ export type ClientDeps = { path: string, signal?: AbortSignal, mutation?: unknown, - method?: "POST" | "PATCH", + method?: "POST" | "PATCH" | "DELETE", ) => Promise; catalogResponse: (path: string, signal?: AbortSignal) => Promise; baseUrl: URL; diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index fa863b98..fed6bd52 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -62,7 +62,7 @@ export function createProductApiClient( path: string, signal?: AbortSignal, mutation?: unknown, - method: "POST" | "PATCH" = "POST", + method: "POST" | "PATCH" | "DELETE" = "POST", ): Promise { const requestHeaders = new Headers(headers); if (mutation !== undefined) diff --git a/packages/schemas/src/models.ts b/packages/schemas/src/models.ts index 95206fd6..f2e03446 100644 --- a/packages/schemas/src/models.ts +++ b/packages/schemas/src/models.ts @@ -7,13 +7,17 @@ export const ProviderStatus = type( ); export type ProviderStatus = typeof ProviderStatus.infer; -// A key is tried once against the provider's model list before it is kept; it is never returned. -export const ProviderTest = type({ +const testTarget = { "+": "reject", plugin: name, "baseURL?": type("string").atMostLength(2000), +} as const; +// A key is tried once against the provider's model list before it is kept; it is never returned. +// A keyless server is tried with no key at all. +export const ProviderTest = type({ + ...testTarget, secret: type("string > 0").atMostLength(8000), -}); +}).or({ ...testTarget, keyless: "true" }); export type ProviderTest = typeof ProviderTest.infer; export const ProviderTestResult = type({ "+": "reject", @@ -21,22 +25,89 @@ export const ProviderTestResult = type({ }); export type ProviderTestResult = typeof ProviderTestResult.infer; +const tokens = type("number.integer > 0").atMost(100_000_000); + +// A model a person serves from their own endpoint, with the window and output cap every role runs it with. +export const CustomModel = type({ + "+": "reject", + id: name, + // The name a person gave it; without one it is shown by its id. + "label?": name, + contextWindow: tokens, + maxOutputTokens: tokens, +}).narrow( + (model, ctx) => + model.maxOutputTokens < model.contextWindow || + ctx.mustBe("an output cap below the context window"), +); +export type CustomModel = typeof CustomModel.infer; + +// The wire API a custom provider speaks, named by the native adapter plugin that serves it. +export const CustomApi = type( + "'openai-compatible' | 'openai-responses' | 'anthropic'", +); +export type CustomApi = typeof CustomApi.infer; + +const customModels = CustomModel.array() + .atLeastLength(1) + .atMostLength(50) + .narrow( + (models, ctx) => + new Set(models.map((model) => model.id)).size === models.length || + ctx.mustBe("models with distinct ids"), + ); + +// A key is pinned to the origin it was entered for, so a new base URL, API or key is a new connection. +const endpoint = { + "+": "reject", + api: CustomApi, + baseURL: type("string.url").atMostLength(2000), +} as const; +// A server that needs no key is marked keyless instead of given one. +const CustomConnection = type({ + ...endpoint, + secret: type("string > 0").atMostLength(8000), +}).or({ ...endpoint, keyless: "true" }); + +export const CustomProviderCreate = type({ + "+": "reject", + name, + models: customModels, + connection: CustomConnection, +}); +export type CustomProviderCreate = typeof CustomProviderCreate.infer; + +export const CustomProviderUpdate = type({ + "+": "reject", + name, + models: customModels, + "connection?": CustomConnection, +}); +export type CustomProviderUpdate = typeof CustomProviderUpdate.infer; + +export const CustomProviderReceipt = type({ "+": "reject", id: name }); +export type CustomProviderReceipt = typeof CustomProviderReceipt.infer; + export const ModelProvider = type({ "+": "reject", id: name, plugin: name, - kind: "'api_key' | 'login'", + kind: "'api_key' | 'login' | 'custom'", label: name, hint: name, - needsBaseUrl: "boolean", baseURL: "string", - models: type({ "+": "reject", id: name, label: name }).array(), + models: type({ "+": "reject", id: name, label: name }) + .or(CustomModel) + .array(), connections: type({ "+": "reject", id: name, name, - status: ProviderStatus, + // Omitted for a custom endpoint, which is probed only on demand, unless it is disabled. + "status?": ProviderStatus, credentialSet: "boolean", + // A custom endpoint's: whether it runs with no key. + "keyless?": "boolean", // Where the connection comes from when a person did not make it, such as the server's keys. "note?": "string", }).array(), @@ -150,6 +221,8 @@ export const ModelCatalog = type({ }).array(), tiers: type({ "+": "reject", id: Tier, label: name }).array(), efforts: type({ "+": "reject", id: Effort, label: name }).array(), + // The APIs a custom provider can speak. + customApis: type({ "+": "reject", id: CustomApi, label: name }).array(), // The profile a person without a choice runs on: the only connected provider's. "automaticProfile?": name, // Why there is no automatic profile, when there is none. diff --git a/packages/schemas/src/settings.ts b/packages/schemas/src/settings.ts index b7e772bb..d93c6693 100644 --- a/packages/schemas/src/settings.ts +++ b/packages/schemas/src/settings.ts @@ -93,7 +93,8 @@ export const SettingsProviders = type({ "+": "reject", id: "string > 0", name: "string > 0", - availability: ProviderAvailability, + // Omitted for a custom endpoint, which is probed only on demand. + "availability?": ProviderAvailability, secrets: { "+": "reject", credential: { "+": "reject", set: "boolean" } }, }).array(), }); diff --git a/test/e2e/custom-provider.test.ts b/test/e2e/custom-provider.test.ts new file mode 100644 index 00000000..830f09a8 --- /dev/null +++ b/test/e2e/custom-provider.test.ts @@ -0,0 +1,418 @@ +import { expect, test } from "bun:test"; +import { randomBytes, randomUUID } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import postgres from "postgres"; +import { createDB, listVisibleOfferings } from "@intx/db"; +import { createAgentRepoStore, createAssetService } from "@intx/hub-sessions"; +import { generateKeyPair } from "@intx/crypto"; +import { createHubRuntime } from "@corbits-builder/hub/app"; +import { readDatabaseConfig } from "@corbits-builder/hub/environment"; +import { migrateDatabase } from "@corbits-builder/hub/migrate"; +import { installForTenant } from "@corbits-builder/hub/workflows/install-for-tenant"; +import { defaultSettings } from "@corbits-builder/hub/settings/policy"; +import type { CustomModel, ModelChoice } from "@corbits-builder/schemas/models"; +import { + ProductApiError, + createProductApiClient, +} from "@corbits-builder/client"; + +function required(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`Missing required e2e environment: ${name}`); + return value; +} + +// A fake endpoint that answers the OpenAI and Anthropic model lists and records each key it is sent. +function fakeEndpoint() { + const seen: (string | null)[] = []; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + seen.push( + request.headers.get("authorization") ?? + request.headers.get("x-api-key"), + ); + return new URL(request.url).pathname === "/v1/models" + ? Response.json({ data: [{ id: "local-model" }] }) + : new Response(null, { status: 404 }); + }, + }); + return { seen, server, origin: `http://127.0.0.1:${server.port}` }; +} + +// A person adds their own endpoints, each on the API it speaks, binds a profile to their models, +// and a project's install pins offerings at those endpoints. A key stays on its origin, edits reach +// only the workspace that owns the provider, and removing it leaves nothing in the catalog. The +// endpoints are local fakes; the keys are fake. +test("custom providers install on their API, keep each key on its origin, and remove cleanly", async () => { + const database = { + host: required("E2E_DB_HOST"), + port: Number(required("E2E_DB_PORT")), + username: required("E2E_DB_USER"), + password: required("E2E_DB_PASSWORD"), + }; + const dbName = `sb_custom_${randomUUID().replaceAll("-", "")}`; + const admin = postgres({ + ...database, + database: "postgres", + max: 1, + onnotice: () => undefined, + }); + await admin.unsafe(`create database ${dbName}`); + const directory = await mkdtemp(join(tmpdir(), "sb-custom-provider-")); + const fake = () => `fake-custom-${randomBytes(8).toString("hex")}`; + const [key, second, replacement] = [fake(), fake(), fake()]; + const home = fakeEndpoint(); + const elsewhere = fakeEndpoint(); + const origin = "http://127.0.0.1:1"; + const environment: Record = { + NODE_ENV: "test", + HOST: "127.0.0.1", + // Never bound: requests go straight to the app. + PORT: "1", + DB_HOST: database.host, + DB_PORT: String(database.port), + DB_USER: database.username, + DB_PASSWORD: database.password, + DB_NAME: dbName, + DB_SSL: "false", + BETTER_AUTH_BASE_URL: origin, + BETTER_AUTH_SECRET: randomBytes(32).toString("hex"), + CREDENTIAL_ENCRYPTION_KEY: randomBytes(32).toString("hex"), + PRINCIPAL_KEY_ENCRYPTION_KEY: randomBytes(32).toString("hex"), + HUB_DATA_DIR: join(directory, "hub"), + }; + let runtime: Awaited> | undefined; + const sql = postgres({ + ...database, + database: dbName, + max: 1, + onnotice: () => undefined, + }); + try { + await migrateDatabase(environment); + runtime = await createHubRuntime(environment); + const app = runtime.app; + const responses: string[] = []; + const signUp = await app.fetch( + new Request(`${origin}/api/auth/sign-up/email`, { + method: "POST", + headers: { origin, "content-type": "application/json" }, + body: JSON.stringify({ + email: `custom-${dbName}@example.invalid`, + password: randomBytes(16).toString("hex"), + name: "Custom e2e", + }), + }), + ); + expect(signUp.status).toBe(200); + const cookie = signUp.headers + .getSetCookie() + .map((value) => value.split(";")[0]) + .join("; "); + const workspace = async (name: string) => { + const created = await app.fetch( + new Request(`${origin}/api/tenants`, { + method: "POST", + headers: { origin, cookie, "content-type": "application/json" }, + body: JSON.stringify({ + name, + slug: `${name}-${dbName}`.replaceAll("_", "-").toLowerCase(), + }), + }), + ); + expect(created.status).toBe(201); + return ((await created.json()) as { id: string }).id; + }; + const tenantId = await workspace("Custom"); + const client = createProductApiClient({ + baseUrl: origin, + headers: { cookie, origin }, + fetch: async (request) => { + const response = await app.fetch(request); + responses.push(await response.clone().text()); + return response; + }, + }); + const refusal = async (work: Promise) => { + try { + await work; + } catch (error) { + if (!(error instanceof ProductApiError)) throw error; + return { + status: error.response.status, + ...((await error.response.clone().json()) as object), + }; + } + throw new Error("expected a refusal"); + }; + const catalogRows = async () => { + const [row] = await sql`select + (select count(*)::int from provider where name like 'byok-% http%') as providers, + (select count(*)::int from credential where name like 'custom-provider %') as credentials, + (select count(*)::int from model_provider where name like 'custom-provider %') as connections, + (select count(*)::int from sb_custom_model_provider) as kept`; + return { ...row }; + }; + + const local = { + id: "local-model", + label: "Local Model", + contextWindow: 131072, + maxOutputTokens: 16384, + } satisfies CustomModel; + const keyed = await client.createCustomProvider(tenantId, { + name: "Local", + models: [local], + connection: { + api: "openai-compatible", + baseURL: `${home.origin}/v1`, + secret: key, + }, + }); + const messages = await client.createCustomProvider(tenantId, { + name: "Messages", + models: [local], + connection: { api: "anthropic", baseURL: home.origin, secret: second }, + }); + expect(await catalogRows()).toEqual({ + providers: 2, + credentials: 2, + connections: 2, + kept: 2, + }); + + // Reading settings sends nothing to a custom endpoint; the availability route probes on demand. + const catalog = await client.modelCatalog(tenantId); + expect(home.seen).toEqual([]); + const listed = catalog.providers.find((entry) => entry.id === keyed.id)!; + expect(listed).toMatchObject({ + kind: "custom", + plugin: "openai-compatible", + label: "Local", + models: [local], + }); + expect(listed.connections).toEqual([ + { + id: expect.any(String), + name: "Local", + credentialSet: true, + keyless: false, + }, + ]); + const connection = listed.connections[0]!.id; + const availability = await app.fetch( + new Request( + `${origin}/api/tenants/${tenantId}/availability/models/${connection}`, + { headers: { origin, cookie } }, + ), + ); + expect(await availability.json()).toMatchObject({ state: "connected" }); + expect(home.seen).toEqual([`Bearer ${key}`]); + expect(catalog.unresolved?.code).toBe("model_profile_custom"); + + const tiers = { + low: { provider: messages.id, model: local.id, effort: "low" }, + medium: { provider: keyed.id, model: local.id, effort: "medium" }, + high: { provider: keyed.id, model: local.id, effort: "high" }, + max: { provider: keyed.id, model: local.id, effort: "max" }, + } as const; + const choice: ModelChoice = { + profile: "mine", + custom: [{ id: "mine", label: "Mine", tiers }], + }; + const mine = await client.personalSettings(tenantId); + const saved = await client.savePersonalSettings(tenantId, { + command: "save_settings", + idempotencyKey: randomUUID(), + fields: { + models: { + value: choice, + baseVersion: mine.fields.models?.version ?? 0, + }, + }, + }); + expect(saved.savedFields).toEqual(["models"]); + + // The install a project start runs, on the project settings row create_project writes. A + // second install finds the first current and leaves the workspace config as it was. + const handle = createDB(readDatabaseConfig(environment)); + const projectId = `p-${randomUUID().replaceAll("-", "")}`; + try { + const [principal] = + await sql`select id from principal where tenant_id = ${tenantId} and kind = 'user'`; + await sql`insert into sb_settings (tenant_id, scope_key, fields) + values (${tenantId}, ${`project:${projectId}`}, ${sql.json({ ...defaultSettings(), models: { value: choice, version: 1 } })})`; + const signingKey = await generateKeyPair(); + const install = () => + installForTenant( + { + db: handle.db, + assets: createAssetService({ + db: handle.db, + repoStore: createAgentRepoStore({ + dataDir: join(directory, "repos"), + signingKey, + }).repoStore, + }), + }, + { tenantId, principalId: String(principal!.id) }, + projectId, + ); + const installed = await install(); + const config = async () => + (await sql`select config from tenant where id = ${tenantId}`)[0]! + .config; + const first = await config(); + await install(); + expect(await config()).toEqual(first); + const visible = await listVisibleOfferings(handle.db, tenantId); + const pinned = (role: "coder" | "reviewer") => { + const offering = visible.find( + (entry) => + entry.offering.id === installed[role].defaultSourceOfferingId, + )!; + return { + model: offering.model.canonicalName, + plugin: offering.provider.plugin, + baseURL: offering.provider.baseURL, + }; + }; + expect(pinned("coder")).toEqual({ + model: local.id, + plugin: "openai-compatible", + baseURL: `${home.origin}/v1`, + }); + expect(pinned("reviewer")).toEqual({ + model: local.id, + plugin: "anthropic", + baseURL: home.origin, + }); + } finally { + await handle.close(); + } + + // An edit without a connection renames and replaces models on the same connection. + const renamed = { ...local, id: "renamed-model", label: "Renamed" }; + await client.updateCustomProvider(tenantId, keyed.id, { + name: "Local renamed", + models: [renamed], + }); + const edited = (await client.modelCatalog(tenantId)).providers.find( + (entry) => entry.id === keyed.id, + )!; + expect(edited).toMatchObject({ label: "Local renamed", models: [renamed] }); + expect(edited.connections[0]!.id).toBe(connection); + + // A new origin is a new connection with its own key; the provider keeps its id, the old key + // is gone, and neither origin is sent the other's key. + await client.updateCustomProvider(tenantId, keyed.id, { + name: "Local renamed", + models: [renamed], + connection: { + api: "openai-compatible", + baseURL: `${elsewhere.origin}/v1`, + secret: replacement, + }, + }); + const moved = (await client.modelCatalog(tenantId)).providers.find( + (entry) => entry.id === keyed.id, + )!; + expect(moved.baseURL).toBe(`${elsewhere.origin}/v1`); + const movedConnection = moved.connections[0]!.id; + expect(movedConnection).not.toBe(connection); + await app.fetch( + new Request( + `${origin}/api/tenants/${tenantId}/availability/models/${movedConnection}`, + { headers: { origin, cookie } }, + ), + ); + expect(elsewhere.seen).toEqual([`Bearer ${replacement}`]); + expect(home.seen).not.toContain(`Bearer ${replacement}`); + expect(await catalogRows()).toEqual({ + providers: 2, + credentials: 2, + connections: 2, + kept: 2, + }); + + // Another workspace of the same person cannot edit or remove it. + const other = await workspace("Other"); + expect( + await refusal( + client.updateCustomProvider(other, keyed.id, { + name: "Taken", + models: [local], + }), + ), + ).toMatchObject({ status: 404, code: "custom_provider_not_found" }); + expect( + await refusal(client.removeCustomProvider(other, keyed.id)), + ).toMatchObject({ status: 404, code: "custom_provider_not_found" }); + + // A server that needs no key is tested with none, and connects keyless. + const asked = home.seen.length; + expect( + await client.testProvider(tenantId, { + plugin: "openai-compatible", + baseURL: `${home.origin}/v1`, + keyless: true, + }), + ).toEqual({ status: "connected" }); + expect(home.seen.slice(asked)).toEqual([null]); + const unnamed = { + id: local.id, + contextWindow: 8192, + maxOutputTokens: 1024, + }; + const keyless = await client.createCustomProvider(tenantId, { + name: "Keyless", + models: [unnamed], + connection: { + api: "openai-compatible", + baseURL: `${home.origin}/v1`, + keyless: true, + }, + }); + expect( + (await client.modelCatalog(tenantId)).providers.find( + (entry) => entry.id === keyless.id, + ), + ).toMatchObject({ + models: [unnamed], + connections: [ + { + id: expect.any(String), + name: "Keyless", + credentialSet: true, + keyless: true, + }, + ], + }); + + // Removing a provider a profile binds is allowed; the profile then stops being runnable. + await client.removeCustomProvider(tenantId, keyed.id); + await client.removeCustomProvider(tenantId, messages.id); + await client.removeCustomProvider(tenantId, keyless.id); + expect(await catalogRows()).toEqual({ + providers: 0, + credentials: 0, + connections: 0, + kept: 0, + }); + for (const body of responses) + for (const secret of [key, second, replacement]) + expect(body.includes(secret)).toBe(false); + } finally { + await sql.end(); + await runtime?.close(); + await home.server.stop(true); + await elsewhere.server.stop(true); + await admin.unsafe(`drop database if exists ${dbName} with (force)`); + await admin.end(); + await rm(directory, { recursive: true, force: true }); + } +}, 180_000); diff --git a/test/e2e/oauth-model-login.test.ts b/test/e2e/oauth-model-login.test.ts index 3412dfc7..0a9fc8ff 100644 --- a/test/e2e/oauth-model-login.test.ts +++ b/test/e2e/oauth-model-login.test.ts @@ -17,7 +17,10 @@ import { createHubRuntime } from "@corbits-builder/hub/app"; import { readDatabaseConfig } from "@corbits-builder/hub/environment"; import { migrateDatabase } from "@corbits-builder/hub/migrate"; import { installForTenant } from "@corbits-builder/hub/workflows/install-for-tenant"; -import { frozenModelChoice } from "@corbits-builder/hub/config/models"; +import { + MODEL_PROVIDERS, + frozenModelChoice, +} from "@corbits-builder/hub/config/models"; import { defaultSettings } from "@corbits-builder/hub/settings/policy"; import type { ModelChoice } from "@corbits-builder/schemas/models"; import { createProductApiClient } from "@corbits-builder/client"; @@ -320,7 +323,10 @@ test("ChatGPT and xAI logins are providers a profile binds roles to, and install const projectId = `p-${randomUUID().replaceAll("-", "")}`; const fields = { ...defaultSettings(), - models: { value: frozenModelChoice(chosen), version: 1 }, + models: { + value: frozenModelChoice(MODEL_PROVIDERS, chosen), + version: 1, + }, }; const projectSql = postgres({ ...database,