Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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`
Expand Down
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/connections/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
52 changes: 52 additions & 0 deletions packages/connections/src/connected-hook.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
47 changes: 47 additions & 0 deletions packages/connections/src/connected-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> | void;

export async function fireInferenceCredentialSeedableHook(
hook: InferenceCredentialSeedableHook | undefined,
log: (line: string) => void,
info: InferenceCredentialSeedableInfo,
): Promise<void> {
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}`,
);
}
}
138 changes: 138 additions & 0 deletions packages/connections/src/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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();
};
Expand Down Expand Up @@ -107,6 +117,7 @@ function mountAs(routes: Hono<TenantEnv>): Hono<TenantEnv> {
const asTenant: MiddlewareHandler<TenantEnv> = async (c, next) => {
c.set("tenant", TENANT);
c.set("principal", PRINCIPAL);
c.set("user", USER);
await next();
};
const app = new Hono<TenantEnv>();
Expand Down Expand Up @@ -138,6 +149,12 @@ function buildApp(
typeof createConnectionRoutes
>[0]["listConnectedProviders"];
onConnected?: Parameters<typeof createConnectionRoutes>[0]["onConnected"];
onInferenceCredentialUsable?: Parameters<
typeof createConnectionRoutes
>[0]["onInferenceCredentialUsable"];
getResolvedCatalogFn?: Parameters<
typeof createConnectionRoutes
>[0]["getResolvedCatalogFn"];
} = {},
) {
const routeArgs: Parameters<typeof createConnectionRoutes>[0] = {
Expand All @@ -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);
}
Expand Down Expand Up @@ -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<Record<string, ConnectorDescriptor>> = {
...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);
});
});
Loading
Loading