diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 9b2cc371..52944aa6 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -280,7 +280,11 @@ import { createPresenceRoutes, type PresenceRoomKey, } from "@corbits/presence"; -import { createGitWorkflowPusher, createHubAPI } from "@workbench/hub-client"; +import { + createGitWorkflowPusher, + createHubAPI, + supportedCredentialProviders, +} from "@workbench/hub-client"; import { createDrizzlePendingSeedStore, createBenchProvisioner, @@ -2054,6 +2058,35 @@ export async function createHub(config: HubConfig) { ? { github: config.githubApiBaseUrl } : {}, onConnected: settleServiceConnection, + // CL-6568's other half: a tenant whose only provider is one it + // connected itself through Settings — never an operator-configured + // hub key — must converge on Myra and the default workflow set the + // same way an onboarding-connected one does. `pendingSeedStore` and + // `benchProvisioner` are declared further down this function, but + // this closure only runs on a future request, well after both are + // constructed below — the same forward-reference this file already + // relies on for `onboardingDeps`. + onInferenceCredentialUsable: async (info) => { + const provider = supportedCredentialProviders().find( + (candidate) => candidate.id === info.provider, + )?.id; + if (provider === undefined) { + log.error`onInferenceCredentialUsable fired for an unsupported provider ${info.provider} on tenant ${info.tenantId}; skipping the pending-seed row`; + return; + } + await pendingSeedStore.put({ + userId: info.userId, + tenantId: info.tenantId, + principalId: info.principalId, + tenantDomain: info.tenantDomain, + provider, + apiKey: info.apiKey, + ...(info.baseURLOverride !== undefined + ? { baseURLOverride: info.baseURLOverride } + : {}), + }); + benchProvisioner.wake(); + }, }), ); // Connections' own OAuth connect flow (CL-6389): `createOAuthConnectRoutes` diff --git a/bun.lock b/bun.lock index f0522267..6eada6c9 100644 --- a/bun.lock +++ b/bun.lock @@ -558,6 +558,7 @@ "version": "0.0.1", "dependencies": { "@corbits/credential-providers": "workspace:*", + "@corbits/inference-settings": "workspace:*", "@corbits/mcp-tools": "workspace:*", "@intx/crypto": "0.3.0", "@intx/db": "workspace:*", diff --git a/packages/connections/package.json b/packages/connections/package.json index 5f985302..57f5ea44 100644 --- a/packages/connections/package.json +++ b/packages/connections/package.json @@ -19,6 +19,7 @@ }, "dependencies": { "@corbits/credential-providers": "workspace:*", + "@corbits/inference-settings": "workspace:*", "@corbits/mcp-tools": "workspace:*", "@intx/crypto": "0.3.0", "@intx/db": "workspace:*", diff --git a/packages/connections/src/connected-hook.test.ts b/packages/connections/src/connected-hook.test.ts new file mode 100644 index 00000000..f983275b --- /dev/null +++ b/packages/connections/src/connected-hook.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; +import { + fireInferenceCredentialSeedableHook, + type InferenceCredentialSeedableInfo, +} from "./connected-hook"; + +function seedableInfo(): InferenceCredentialSeedableInfo { + return { + userId: "user_1", + tenantId: "tenant_1", + tenantDomain: "tenant-1.example", + principalId: "principal_1", + provider: "ollama", + apiKey: "secret", + }; +} + +describe("fireInferenceCredentialSeedableHook", () => { + test("does nothing when no hook is wired", async () => { + await expect( + fireInferenceCredentialSeedableHook(undefined, () => {}, seedableInfo()), + ).resolves.toBeUndefined(); + }); + + test("calls the hook with the connect's tenant, provider, and key", async () => { + const calls: InferenceCredentialSeedableInfo[] = []; + await fireInferenceCredentialSeedableHook( + (info) => { + calls.push(info); + }, + () => {}, + seedableInfo(), + ); + expect(calls).toEqual([seedableInfo()]); + }); + + test("logs and swallows a hook failure rather than breaking the connect", async () => { + const logged: string[] = []; + await expect( + fireInferenceCredentialSeedableHook( + () => { + throw new Error("drain unavailable"); + }, + (line) => logged.push(line), + seedableInfo(), + ), + ).resolves.toBeUndefined(); + expect(logged).toHaveLength(1); + expect(logged[0]).toContain("tenant_1"); + expect(logged[0]).toContain("drain unavailable"); + }); +}); diff --git a/packages/connections/src/connected-hook.ts b/packages/connections/src/connected-hook.ts index 3a237670..8ffd4dd4 100644 --- a/packages/connections/src/connected-hook.ts +++ b/packages/connections/src/connected-hook.ts @@ -32,3 +32,50 @@ export async function fireConnectedHook( ); } } + +// A second, narrower seam: fires only when an inference connector's +// `/complete` just left the tenant with `hasUsableModel` true — a model +// with a resolvable offering, whether or not this tenant's bench has +// ever deployed its default workflows. A composition wires this to the +// same durable pending-seed drain the onboarding credential step already +// feeds (`@workbench/onboarding`'s `pendingSeedStore` + `benchProvisioner`), +// so a tenant that connects its own provider through Settings converges +// on Myra and the default workflow set exactly like one that connects +// through onboarding — never stuck waiting on an operator-configured +// hub key. `apiKey` here is the same secret `/complete` just stored +// (the URL placeholder for a `credentialInputKind: "url"` connector like +// Ollama, a real key otherwise); never logged, never returned to the +// caller. +export type InferenceCredentialSeedableInfo = { + readonly userId: string; + readonly tenantId: string; + readonly tenantDomain: string; + readonly principalId: string; + readonly provider: string; + readonly apiKey: string; + /** The real instance origin a `credentialInputKind: "url"` connector + * (Ollama) was just pointed at — absent for every other provider. + * Carried through to the drain so its deploy targets this tenant's + * actual endpoint rather than a curated default. */ + readonly baseURLOverride?: string; +}; + +export type InferenceCredentialSeedableHook = ( + info: InferenceCredentialSeedableInfo, +) => Promise | void; + +export async function fireInferenceCredentialSeedableHook( + hook: InferenceCredentialSeedableHook | undefined, + log: (line: string) => void, + info: InferenceCredentialSeedableInfo, +): Promise { + if (hook === undefined) return; + try { + await hook(info); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + log( + `onInferenceCredentialUsable hook failed for ${info.provider} on tenant ${info.tenantId}: ${message}`, + ); + } +} diff --git a/packages/connections/src/routes.test.ts b/packages/connections/src/routes.test.ts index b6446b00..c58d6669 100644 --- a/packages/connections/src/routes.test.ts +++ b/packages/connections/src/routes.test.ts @@ -9,6 +9,7 @@ import { describe, expect, test } from "bun:test"; import { Hono } from "hono"; import type { MiddlewareHandler } from "hono"; import type { RequireGrant, TenantEnv } from "@intx/hub-api"; +import type { ModelInfo } from "@intx/types"; import type { ConnectorDescriptor } from "./descriptor"; import type { ApiCall } from "@workbench/hub-client"; import { createProviderHealthStore } from "./provider-health"; @@ -35,6 +36,15 @@ const PRINCIPAL = { updatedAt: new Date(), }; +const USER = { + id: "user_alice", + createdAt: new Date(), + updatedAt: new Date(), + email: "alice@example.test", + emailVerified: true, + name: "Alice", +}; + const allowAll: RequireGrant = () => async (_c, next) => { await next(); }; @@ -107,6 +117,7 @@ function mountAs(routes: Hono): Hono { const asTenant: MiddlewareHandler = async (c, next) => { c.set("tenant", TENANT); c.set("principal", PRINCIPAL); + c.set("user", USER); await next(); }; const app = new Hono(); @@ -138,6 +149,12 @@ function buildApp( typeof createConnectionRoutes >[0]["listConnectedProviders"]; onConnected?: Parameters[0]["onConnected"]; + onInferenceCredentialUsable?: Parameters< + typeof createConnectionRoutes + >[0]["onInferenceCredentialUsable"]; + getResolvedCatalogFn?: Parameters< + typeof createConnectionRoutes + >[0]["getResolvedCatalogFn"]; } = {}, ) { const routeArgs: Parameters[0] = { @@ -161,6 +178,11 @@ function buildApp( routeArgs.listConnectedProviders = overrides.listConnectedProviders; if (overrides.onConnected !== undefined) routeArgs.onConnected = overrides.onConnected; + if (overrides.onInferenceCredentialUsable !== undefined) + routeArgs.onInferenceCredentialUsable = + overrides.onInferenceCredentialUsable; + if (overrides.getResolvedCatalogFn !== undefined) + routeArgs.getResolvedCatalogFn = overrides.getResolvedCatalogFn; const routes = createConnectionRoutes(routeArgs); return mountAs(routes); } @@ -1038,3 +1060,119 @@ describe("onConnected hook", () => { expect(events).toHaveLength(0); }); }); + +function modelWithOfferings(offeringCount: number): ModelInfo { + return { + id: "model_1", + canonicalName: "qwen3", + displayName: "Qwen3", + offerings: Array.from({ length: offeringCount }, (_, index) => ({ + offeringId: `off_${index}`, + providerId: "prov_1", + providerName: "ollama", + plugin: "ollama", + priority: index, + capabilities: [], + })), + } as unknown as ModelInfo; +} + +describe("onInferenceCredentialUsable hook", () => { + const OLLAMA_REGISTRY: Readonly> = { + ...FAKE_REGISTRY, + ollama: { + id: "ollama", + displayName: "Ollama", + authKind: "api-key", + credentialPlugin: "http", + docsUrl: "https://example.test/docs", + feedsTools: [], + credentialInputKind: "url", + credentialPlaceholder: "http://localhost:11434", + probe: async () => ({ ok: true }), + }, + }; + + test("a connected provider that resolves a usable model fires the hook with that provider's own key and endpoint", async () => { + const events: unknown[] = []; + const routes = createConnectionRoutes({ + hubUrl: "http://hub.test", + requireGrant: allowAll, + log: () => {}, + registry: OLLAMA_REGISTRY, + ensureProviderFn: async () => "prv_1", + ensureCredentialFn: async () => "crd_1", + seedCatalogFn: async () => ({ hasCompletionCapableModel: true }), + getResolvedCatalogFn: async () => [modelWithOfferings(1)], + onInferenceCredentialUsable: async (info) => { + events.push(info); + }, + }); + const app = mountAs(routes); + const response = await app.request("/ollama/complete", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + apiKey: "https://home-mac-studio.tail87f5aa.ts.net", + }), + }); + + expect(response.status).toBe(200); + expect(events).toEqual([ + { + userId: USER.id, + tenantId: TENANT.id, + tenantDomain: TENANT.domain, + principalId: PRINCIPAL.id, + provider: "ollama", + apiKey: "ollama", + baseURLOverride: "https://home-mac-studio.tail87f5aa.ts.net", + }, + ]); + }); + + test("a connected provider that resolves no usable model never fires the hook — a seeded row is not the same as a usable one", async () => { + const events: unknown[] = []; + const routes = createConnectionRoutes({ + hubUrl: "http://hub.test", + requireGrant: allowAll, + log: () => {}, + registry: OLLAMA_REGISTRY, + ensureProviderFn: async () => "prv_1", + ensureCredentialFn: async () => "crd_1", + seedCatalogFn: async () => ({ hasCompletionCapableModel: false }), + getResolvedCatalogFn: async () => [modelWithOfferings(0)], + onInferenceCredentialUsable: async (info) => { + events.push(info); + }, + }); + const app = mountAs(routes); + const response = await app.request("/ollama/complete", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ apiKey: "http://localhost:11434" }), + }); + + expect(response.status).toBe(200); + expect(events).toHaveLength(0); + }); + + test("a non-inference connector never fires the hook", async () => { + const events: unknown[] = []; + const app = buildApp({ + ensureProviderFn: async () => "prv_1", + ensureCredentialFn: async () => "crd_1", + onInferenceCredentialUsable: async (info) => { + events.push(info); + }, + }); + const response = await app.request("/accepting-connector/complete", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ apiKey: "good-key" }), + }); + + expect(response.status).toBe(200); + expect(events).toHaveLength(0); + }); +}); diff --git a/packages/connections/src/routes.ts b/packages/connections/src/routes.ts index 2402bc9e..c2b713c7 100644 --- a/packages/connections/src/routes.ts +++ b/packages/connections/src/routes.ts @@ -15,11 +15,13 @@ import { Hono, type Context } from "hono"; import { type } from "arktype"; import { + ModelInfo, ModelProviderResponse, ProviderResponse, paginatedSchema, } from "@intx/types"; import type { RequireGrant, TenantEnv } from "@intx/hub-api"; +import { hasUsableModel } from "@corbits/inference-settings"; import { cookiesFromHeader, createHubAPI, @@ -34,8 +36,16 @@ import { type SeedCatalogArgs, } from "@workbench/hub-client"; import type { ConnectorDescriptor } from "./descriptor"; -import { fireConnectedHook, type ServiceConnectedHook } from "./connected-hook"; -import { persistConnectorCredential } from "./persist-credential"; +import { + fireConnectedHook, + fireInferenceCredentialSeedableHook, + type InferenceCredentialSeedableHook, + type ServiceConnectedHook, +} from "./connected-hook"; +import { + isInferenceProvider, + persistConnectorCredential, +} from "./persist-credential"; import type { ProviderHealthStore } from "./provider-health"; import { CONNECTOR_REGISTRY } from "./registry"; @@ -197,6 +207,15 @@ export type CreateConnectionRoutesDeps = { * inference-provider connect seeds the catalog (and a non-inference * connector never does) without reaching for module mocking. */ seedCatalogFn?: (args: SeedCatalogArgs) => ReturnType; + /** Test-only override, matching every other override in this file — + * lets `routes.test.ts` stub the post-connect resolved-catalog read + * `onInferenceCredentialUsable`'s `hasUsableModel` gate runs against, + * without reaching for module mocking. */ + getResolvedCatalogFn?: ( + api: ApiCall, + cookies: string[], + tenantId: string, + ) => Promise; /** Test-only override, matching every other override in this file — * lets `routes.test.ts` stub disconnect's catalog/provider cleanup * without reaching for module mocking. */ @@ -255,6 +274,17 @@ export type CreateConnectionRoutesDeps = { * connect cards, resume waiting agents). Failures are logged and * never surface into the response. */ onConnected?: ServiceConnectedHook; + /** Fires once an inference connector's credential is durably stored + * AND leaves the tenant with `hasUsableModel` true — never on a + * non-inference connector, and never merely because a credential row + * exists (seeding plants that row regardless of whether it actually + * resolves an offering). The composition wires this to the same + * durable pending-seed drain onboarding's own credential step feeds, + * so a provider connected through Settings deploys the tenant's + * default workflows exactly like one connected through onboarding — + * see `./connected-hook.ts`. Absent means this hub build never + * re-seeds off a Settings connect (every existing test double). */ + onInferenceCredentialUsable?: InferenceCredentialSeedableHook; }; export function createConnectionRoutes( @@ -265,6 +295,21 @@ export function createConnectionRoutes( const registry = deps.registry ?? CONNECTOR_REGISTRY; const runDisconnectConnector = deps.disconnectConnectorFn ?? disconnectConnector; + const runGetResolvedCatalog = + deps.getResolvedCatalogFn ?? + (async (resolveApi: ApiCall, cookies: string[], tenantId: string) => { + const response = await resolveApi( + "GET", + `/api/tenants/${tenantId}/models`, + undefined, + cookies, + ); + return parseAs( + ModelInfo.array(), + response.data, + "resolved catalog response", + ); + }); // Lets a settings-ui OAuth card tell "not configured" (an operator // hasn't registered this connector's OAuth app yet) apart from "not @@ -431,6 +476,52 @@ export function createConnectionRoutes( connectorId: descriptor.id, displayName: descriptor.displayName, }); + // A tenant that just connected its own inference provider is an + // equally valid seed source as an operator-configured hub key — + // it must not sit unseeded forever waiting on one (CL-6568). Ask + // the same resolved-catalog question launch itself asks + // (`hasUsableModel`, `@corbits/inference-settings`) rather than + // trusting the credential row's mere presence, then hand the + // provisioning drain this connector's own provider and key — + // best-effort: a failure here never turns a stored, working + // credential into a failed connect response. + if (isInferenceProvider(descriptor.id) && seedResult !== undefined) { + const user = c.get("user"); + if (user) { + try { + const models = await runGetResolvedCatalog( + api, + cookies, + tenant.id, + ); + if (hasUsableModel(models)) { + await fireInferenceCredentialSeedableHook( + deps.onInferenceCredentialUsable, + deps.log, + { + userId: user.id, + tenantId: tenant.id, + tenantDomain: tenant.domain, + principalId: c.get("principal").id, + provider: descriptor.id, + apiKey: isUrlCredential + ? OLLAMA_PLACEHOLDER_SECRET + : parsed.apiKey, + ...(isUrlCredential + ? { baseURLOverride: parsed.apiKey } + : {}), + }, + ); + } + } catch (cause) { + const message = + cause instanceof Error ? cause.message : String(cause); + deps.log( + `could not check tenant ${tenant.id}'s resolved catalog after connecting ${descriptor.id}; the bench stays as-is until its next reconcile: ${message}`, + ); + } + } + } return c.json( modelGuidance !== undefined ? { credentialId, status: "active" as const, modelGuidance } diff --git a/packages/onboarding/src/bench-provisioning.ts b/packages/onboarding/src/bench-provisioning.ts index e16f393b..33633ed3 100644 --- a/packages/onboarding/src/bench-provisioning.ts +++ b/packages/onboarding/src/bench-provisioning.ts @@ -168,6 +168,9 @@ export function createBenchProvisioner( }, provider: seed.provider, apiKey: seed.apiKey, + ...(seed.baseURLOverride !== undefined + ? { baseURLOverride: seed.baseURLOverride } + : {}), }; const result = await runEnsureSeeded( deps.publishToolRegistry !== undefined diff --git a/packages/onboarding/src/pending-seed.test.ts b/packages/onboarding/src/pending-seed.test.ts index 081c2e6d..a90d1ce5 100644 --- a/packages/onboarding/src/pending-seed.test.ts +++ b/packages/onboarding/src/pending-seed.test.ts @@ -201,6 +201,29 @@ describe("createInMemoryPendingSeedStore", () => { ).resolves.toBeUndefined(); }); + test("round-trips baseURLOverride for an ollama-shaped seed — the drain needs the real instance URL, not a curated default", async () => { + const store = createInMemoryPendingSeedStore(testCipher()); + const ollamaSeed: PendingSeed = { + ...SEED, + provider: "ollama", + baseURLOverride: "https://home-mac-studio.tail87f5aa.ts.net", + }; + await store.put(ollamaSeed); + + const read = await store.read({ userId: "user_1", tenantId: "ten_1" }); + + expect(read).toEqual(ollamaSeed); + }); + + test("omits baseURLOverride when the seed carries none, rather than round-tripping it as undefined", async () => { + const store = createInMemoryPendingSeedStore(testCipher()); + await store.put(SEED); + + const read = await store.read({ userId: "user_1", tenantId: "ten_1" }); + + expect(read).not.toHaveProperty("baseURLOverride"); + }); + test("round-trips for a provider other than the first — every supported provider seals and opens correctly", async () => { const store = createInMemoryPendingSeedStore(testCipher()); const hfSeed: PendingSeed = { ...SEED, provider: "huggingface" }; diff --git a/packages/onboarding/src/pending-seed.ts b/packages/onboarding/src/pending-seed.ts index ab67fea9..265f7376 100644 --- a/packages/onboarding/src/pending-seed.ts +++ b/packages/onboarding/src/pending-seed.ts @@ -91,6 +91,7 @@ const PendingSeedSecret = type({ principalId: "string > 0", tenantDomain: "string > 0", apiKey: "string > 0", + "baseURLOverride?": "string > 0", }); export type PendingSeed = { @@ -100,6 +101,13 @@ export type PendingSeed = { readonly tenantDomain: string; readonly provider: SupportedCredentialProvider; readonly apiKey: string; + /** The instance origin a `credentialInputKind: "url"` connector + * (Ollama) was actually pointed at, connect time — never a curated + * default. Absent for every other provider. Threaded through so the + * drain's `ensureSeeded` call resolves `modelSourceFor` against the + * real instance rather than falling back to `CATALOG_SEEDS.ollama`'s + * fixed origin (CL-6366's same failure mode, one hop later). */ + readonly baseURLOverride?: string; }; export type PendingSeedDb< @@ -211,6 +219,9 @@ function createPendingSeedStore( principalId: parsed.principalId, tenantDomain: parsed.tenantDomain, apiKey: parsed.apiKey, + ...(parsed.baseURLOverride !== undefined + ? { baseURLOverride: parsed.baseURLOverride } + : {}), }; } catch { return drop(); @@ -226,6 +237,9 @@ function createPendingSeedStore( principalId: seed.principalId, tenantDomain: seed.tenantDomain, apiKey: seed.apiKey, + ...(seed.baseURLOverride !== undefined + ? { baseURLOverride: seed.baseURLOverride } + : {}), }), pendingSeedAad(seed.provider), ); diff --git a/packages/onboarding/src/routes.ts b/packages/onboarding/src/routes.ts index 075f737a..5eb621d4 100644 --- a/packages/onboarding/src/routes.ts +++ b/packages/onboarding/src/routes.ts @@ -787,6 +787,9 @@ export function createOnboardingRoutes( tenantDomain: result.tenantDomain, provider: parsed.provider, apiKey: parsed.apiKey, + ...(parsed.baseURL !== undefined + ? { baseURLOverride: parsed.baseURL } + : {}), }); deps.benchProvisioner?.wake(); return c.json(status, 200);