diff --git a/packages/chat/test/platform-adapter.test.ts b/packages/chat/test/platform-adapter.test.ts index e8030b21d..47ba651ab 100644 --- a/packages/chat/test/platform-adapter.test.ts +++ b/packages/chat/test/platform-adapter.test.ts @@ -72,6 +72,19 @@ mock.module("@intx/hub-api", () => ({ }, })); +// `deployAtHead` (reached through `launchFoldedRun`/`launchInvite`) looks +// up `listVisibleOfferings` once per launch to correct an Ollama +// offering's adapter-registry key (CL-6586's `withOllamaAdapterKey`) -- +// the real implementation walks a drizzle `db.query` surface this file's +// minimal chainable fake `db` never implements. None of these fixtures +// resolve against an Ollama offering, so an empty list is the correct +// fake: nothing here should ever need its `provider` field corrected. +const actualDb = await import("@intx/db"); +mock.module("@intx/db", () => ({ + ...actualDb, + listVisibleOfferings: async () => [], +})); + const { createHubChatPlatform } = await import("../src/platform-adapter"); type SelectChain = { diff --git a/packages/folded-runs/src/launch.ts b/packages/folded-runs/src/launch.ts index d6cfae3bf..1d6f0e551 100644 --- a/packages/folded-runs/src/launch.ts +++ b/packages/folded-runs/src/launch.ts @@ -14,7 +14,7 @@ import { eq } from "drizzle-orm"; import { type } from "arktype"; import type { DBExecutor } from "@intx/db"; -import { buildCredentialDelivery } from "@intx/db"; +import { buildCredentialDelivery, listVisibleOfferings } from "@intx/db"; import type { CredentialBinding } from "@intx/types"; import { agentSession, @@ -98,6 +98,39 @@ export function parseSourcesOverride( */ export type FoldedRunMode = AgentRuntimeConfig["mode"]; +/** + * `resolveDefinitionSources` sets `InferenceSource.provider` to the + * winning offering's catalog `plugin` — accurate as the wire format + * (`openai-compatible` for Ollama, since `@corbits/ollama-adapter` wraps + * `createOpenAIAdapter` unmodified) but wrong as an adapter-registry key: + * the sidecar's registry dispatches a locally-served offering through + * `@corbits/ollama-adapter` under the key `"ollama"` + * (`apps/sidecar/src/config.ts`), which a `plugin`-only source can never + * name. Left uncorrected, an Ollama source resolves to the built-in + * OpenAI adapter, whose stricter `quirks` schema rejects the offering's + * `default` bag outright. `plugin` and this dispatch key are deliberately + * different concepts — the DB `plugin` column stays `openai-compatible` + * (`ModelProviderPlugin` has no `"ollama"` member, nor should it); this + * only corrects the in-flight `provider` field a launch pins into its + * run config. + * + * `ollamaOfferingIds` names every offering whose provider row is + * literally `"ollama"` (`@corbits/hub-client`'s `CATALOG_SEEDS.ollama`) — + * the same identity `quirksForDeployment` keys its own override on + * (`@corbits/inference-catalog`'s `ollama-context-defaults.ts`). A + * source whose id names none of them is returned unchanged. + */ +export function withOllamaAdapterKey( + sources: readonly InferenceSource[], + ollamaOfferingIds: ReadonlySet, +): InferenceSource[] { + return sources.map((source) => + ollamaOfferingIds.has(source.id) + ? { ...source, provider: "ollama" } + : source, + ); +} + /** * The ref a folded run's per-run workflow source tree is committed to * inside its definition asset. Per-run rather than the asset's default @@ -302,6 +335,22 @@ export async function deployAtHead( throw new InferenceResolutionError(params.launchLabel, resolution.message); } + // A caller-supplied override already states the adapter key it wants + // (see `SourcesOverride`'s doc) — only a catalog-resolved chain needs + // its `provider` field corrected for Ollama offerings. + if (sourcesOverride === undefined) { + const offerings = await listVisibleOfferings(deps.db, params.tenantId); + const ollamaOfferingIds = new Set( + offerings + .filter((resolved) => resolved.provider.name === "ollama") + .map((resolved) => resolved.offering.id), + ); + resolution.sources = withOllamaAdapterKey( + resolution.sources, + ollamaOfferingIds, + ); + } + // `create` replaces any collector already registered for this // address (see `EventCollectorRegistry.create`), so this is // idempotent whether this is a fresh launch or a wake of an instance diff --git a/packages/folded-runs/test/launch.test.ts b/packages/folded-runs/test/launch.test.ts index 66f5d57af..8b7f8fc5c 100644 --- a/packages/folded-runs/test/launch.test.ts +++ b/packages/folded-runs/test/launch.test.ts @@ -68,12 +68,28 @@ let buildCredentialDeliveryResult: BuildCredentialDeliveryResult = { }; const buildCredentialDeliveryCalls: unknown[] = []; +// `deployAtHead` consults `listVisibleOfferings` once per catalog-resolved +// launch, to correct a source's adapter-registry key when its offering's +// provider is actually named "ollama" (`withOllamaAdapterKey`). Only the +// two fields that decision reads are given here; a real `ResolvedOffering` +// carries far more, none of which this fix touches. +type FakeResolvedOffering = { + offering: { id: string }; + provider: { name: string }; +}; +let listVisibleOfferingsResult: FakeResolvedOffering[] = []; +const listVisibleOfferingsCalls: unknown[] = []; + mock.module("@intx/db", () => ({ ...actualDb, buildCredentialDelivery: async (...args: unknown[]) => { buildCredentialDeliveryCalls.push(args[0]); return buildCredentialDeliveryResult; }, + listVisibleOfferings: async (...args: unknown[]) => { + listVisibleOfferingsCalls.push(args); + return listVisibleOfferingsResult; + }, })); const { @@ -535,6 +551,74 @@ describe("launchFoldedRun", () => { }); }); + // CL-6586: an Ollama-backed offering resolves with `provider: + // "openai-compatible"` — the accurate wire format — but the sidecar's + // adapter registry only recognizes a locally-served source under the + // key "ollama" (`apps/sidecar/src/config.ts`). Left uncorrected, the + // built-in OpenAI adapter serves the request instead and rejects the + // offering's `quirks.default` bag. `deployAtHead` must fix the key up + // before the source reaches the deployed config. + test('corrects the adapter key to "ollama" for a catalog-resolved Ollama offering', async () => { + resolveDefinitionSourcesCalls.length = 0; + resolveDefinitionSourcesResult = { + ok: true, + sources: [ + { + id: "off_ollama", + provider: "openai-compatible", + baseURL: "https://home-mac-studio.tail87f5aa.ts.net/v1", + apiKey: "placeholder", + model: "gpt-oss:20b", + quirks: { default: { numCtx: 131_072, maxOutputTokens: 32_768 } }, + }, + ], + defaultSource: "off_ollama", + }; + listVisibleOfferingsResult = [ + { offering: { id: "off_ollama" }, provider: { name: "ollama" } }, + ]; + listVisibleOfferingsCalls.length = 0; + + const db = createFakeDb(); + const sessionService = createFakeSessionService(); + const eventCollectors = createFakeEventCollectors(); + + await launchFoldedRun( + { + db: db as never, + sessionService, + assetService: createFakeAssetService(), + sidecarRouter: createFakeSidecarRouter(), + toolGrantsForPins: () => [], + eventCollectors, + }, + { + tenantId: "ten_1", + instanceId: "ins_workbench1", + triggerAddress: "ins_workbench1@ten1.workbench.test", + definitionId: "wfd_workbench1", + foldedBody: FOLDED_BODY, + launchLabel: "the workbench host", + }, + ); + + expect(listVisibleOfferingsCalls).toEqual([[db, "ten_1"]]); + const deployed = onlyCall(sessionService.adoptedDeployCalls); + expect(deployed.config.sources).toEqual([ + { + id: "off_ollama", + provider: "ollama", + baseURL: "https://home-mac-studio.tail87f5aa.ts.net/v1", + apiKey: "placeholder", + model: "gpt-oss:20b", + quirks: { default: { numCtx: 131_072, maxOutputTokens: 32_768 } }, + }, + ]); + + // Reset for every test after this one. + listVisibleOfferingsResult = []; + }); + // CL-6164: the step's default input selector (`{ from: // "trigger.payload" }`) reads the triggering mail's bare `content` // verbatim and feeds it straight into `agent.send`, which throws on an @@ -1011,6 +1095,7 @@ describe("launchFoldedRun", () => { ok: false, message: "the catalog must not be consulted when an override is given", }; + listVisibleOfferingsCalls.length = 0; const db = createFakeDb(); const sessionService = createFakeSessionService(); @@ -1051,6 +1136,7 @@ describe("launchFoldedRun", () => { expect(result.sessionId).toBeTruthy(); expect(resolveDefinitionSourcesCalls).toHaveLength(0); + expect(listVisibleOfferingsCalls).toHaveLength(0); expect(sessionService.adoptedDeployCalls).toHaveLength(1); const deployed = sessionService.adoptedDeployCalls[0] as { config: { sources: unknown[]; defaultSource: string }; diff --git a/packages/hub-client/src/credential-test.ts b/packages/hub-client/src/credential-test.ts index f7e2c087f..8dd9f4839 100644 --- a/packages/hub-client/src/credential-test.ts +++ b/packages/hub-client/src/credential-test.ts @@ -31,15 +31,28 @@ export type SupportedCredentialProvider = | "ollama"; /** - * The inference adapter (`@intx/inference`'s runtime provider registry, - * mirrored by `@intx/types`' `ModelProviderPlugin`) that actually serves a - * credential's requests. Deliberately narrower than - * `SupportedCredentialProvider`: OpenRouter, Opencode Zen, Groq, DeepSeek, - * Mistral, and Hugging Face each get their own credential-test probe and onboarding - * card, but at deploy time they all ride the same OpenAI-compatible wire - * shape, so their `ModelSource.provider` and catalog `plugin` value must be - * `"openai-compatible"` — the registry key `byProvider.get(source.provider)` - * resolves against — never their own provider id. + * The wire format (`@intx/inference`'s built-in adapter shape, mirrored by + * `@intx/types`' `ModelProviderPlugin`) that actually serves a credential's + * requests. Deliberately narrower than `SupportedCredentialProvider`: + * OpenRouter, Opencode Zen, Groq, DeepSeek, Mistral, Hugging Face, and + * Ollama each get their own credential-test probe and onboarding card, but + * at deploy time they all ride the same OpenAI-compatible wire shape, so + * their catalog `plugin` value must be `"openai-compatible"` — never their + * own provider id — and `ModelProviderPlugin` gets no wider for them. + * + * This is NOT the same string as the registry key + * `byProvider.get(source.provider)` resolves against (CL-6586). For every + * provider above but Ollama the two happen to be equal, because the + * built-in `"openai-compatible"` adapter is exactly what serves them. Ollama + * is the one exception: it needs `@corbits/ollama-adapter`'s custom factory + * (registered under the key `"ollama"`, `apps/sidecar/src/config.ts`) so an + * offering's `quirks.numCtx` actually reaches `options.num_ctx` — the + * built-in adapter's stricter `quirks` schema rejects that shape outright. + * `packages/folded-runs/src/launch.ts`'s `withOllamaAdapterKey` is the one + * place that correction happens: it leaves `plugin`/`ModelSource.provider` + * as the accurate `"openai-compatible"` wire format and only rewrites the + * launched `InferenceSource.provider` — the actual registry-dispatch + * field — for an offering whose catalog provider is named `"ollama"`. */ export type AdapterPluginId = "anthropic" | "openai" | "openai-compatible" | "google-genai"; diff --git a/packages/tasks/test/launcher.test.ts b/packages/tasks/test/launcher.test.ts index 8851ab0f5..f07aa2110 100644 --- a/packages/tasks/test/launcher.test.ts +++ b/packages/tasks/test/launcher.test.ts @@ -45,6 +45,19 @@ mock.module("@intx/hub-api", () => ({ }, })); +// `deployAtHead` (reached through `launchRun` -> `launchFoldedRun`) looks +// up `listVisibleOfferings` once per launch to correct an Ollama +// offering's adapter-registry key (CL-6586's `withOllamaAdapterKey`) -- +// the real implementation walks a drizzle `db.query` surface this file's +// hand-rolled `createFakeDb` never implements. None of these fixtures +// resolve against an Ollama offering, so an empty list is the correct +// fake: nothing here should ever need its `provider` field corrected. +const actualDb = await import("@intx/db"); +mock.module("@intx/db", () => ({ + ...actualDb, + listVisibleOfferings: async () => [], +})); + const { launchTask, launchTaskLeg,