From f22e97b9f1dbdf78fccff06aaa083278892cfb28 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 14:38:52 -0700 Subject: [PATCH 1/4] test(web): cover repointOfferingModel's model-swap path (CL-8591) --- .../inference/repoint-offering-model.test.ts | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 apps/web/src/settings/inference/repoint-offering-model.test.ts diff --git a/apps/web/src/settings/inference/repoint-offering-model.test.ts b/apps/web/src/settings/inference/repoint-offering-model.test.ts new file mode 100644 index 000000000..1e3ab9a54 --- /dev/null +++ b/apps/web/src/settings/inference/repoint-offering-model.test.ts @@ -0,0 +1,121 @@ +// `repointOfferingModel` is a non-obvious two-step (ensure model, then +// delete+recreate the offering) because the stock offering PATCH has no +// `modelId` field and a model's `canonicalName` is immutable — see +// api.ts's doc on the function. Covers the two cases that make it +// non-trivial: a same-name edit is a no-op (no DELETE/POST at all), and a +// real change carries the offering's priority over to the new row. + +import { afterEach, describe, expect, test } from "bun:test"; + +import { repointOfferingModel } from "./api"; + +const TENANT_ID = "tnt_1"; +const NOW = "2026-01-01T00:00:00.000Z"; + +const realFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = realFetch; +}); + +function pathOf(input: RequestInfo | URL): string { + return typeof input === "string" ? input : new URL(String(input)).pathname; +} + +const OFFERING = { + id: "offering_1", + tenantId: TENANT_ID, + modelId: "model_1", + providerId: "provider_1", + priority: 5, + deploymentTags: [], + capabilities: [], + quirks: null, + disabled: false, + createdAt: NOW, + updatedAt: NOW, +}; + +describe("repointOfferingModel", () => { + test("is a no-op when the canonical name resolves to the offering's own model", async () => { + const calls: { method: string; path: string }[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = init?.method ?? "GET"; + const path = pathOf(input); + calls.push({ method, path }); + // ensureModel's create attempt conflicts; it already exists as model_1. + if (method === "POST" && path.endsWith("/catalog/models")) { + return new Response(null, { status: 409 }); + } + if (method === "GET" && path.endsWith("/catalog/models")) { + return new Response( + JSON.stringify({ + data: [ + { + id: "model_1", + tenantId: TENANT_ID, + canonicalName: "qwen2.5:14b", + displayName: "qwen2.5:14b", + disabled: false, + createdAt: NOW, + updatedAt: NOW, + }, + ], + nextCursor: null, + }), + { status: 200 }, + ); + } + throw new Error(`unexpected call: ${method} ${path}`); + }) as typeof fetch; + + const result = await repointOfferingModel(TENANT_ID, OFFERING, "qwen2.5:14b", "qwen2.5:14b"); + + expect(result).toBe(OFFERING); + expect(calls.some((call) => call.method === "DELETE")).toBe(false); + }); + + test("deletes the old offering and recreates it at the same priority for a new model", async () => { + const calls: { method: string; path: string }[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = init?.method ?? "GET"; + const path = pathOf(input); + calls.push({ method, path }); + if (method === "POST" && path.endsWith("/catalog/models")) { + return new Response( + JSON.stringify({ + id: "model_2", + tenantId: TENANT_ID, + canonicalName: "qwen2.5:32b", + displayName: "qwen2.5:32b", + disabled: false, + createdAt: NOW, + updatedAt: NOW, + }), + { status: 201 }, + ); + } + if (method === "DELETE" && path.endsWith(`/catalog/offerings/${OFFERING.id}`)) { + return new Response(null, { status: 204 }); + } + if (method === "POST" && path.endsWith("/catalog/offerings")) { + const body = JSON.parse(String(init?.body)) as { modelId: string; priority: number }; + return new Response( + JSON.stringify({ + ...OFFERING, + id: "offering_2", + modelId: body.modelId, + priority: body.priority, + }), + { status: 201 }, + ); + } + throw new Error(`unexpected call: ${method} ${path}`); + }) as typeof fetch; + + const result = await repointOfferingModel(TENANT_ID, OFFERING, "qwen2.5:32b", "qwen2.5:32b"); + + expect(result.modelId).toBe("model_2"); + expect(result.priority).toBe(OFFERING.priority); + expect(calls.map((call) => call.method)).toEqual(["POST", "DELETE", "POST"]); + }); +}); From b76de80014f569f26bf99059dbb4d1585faa2a62 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 14:39:03 -0700 Subject: [PATCH 2/4] fix(web): editing a credential's base URL or model updates the catalog rows inference uses (CL-8591) --- apps/web/src/settings/credentials-api.ts | 4 - apps/web/src/settings/credentials-section.tsx | 169 ++++++++++++++---- apps/web/src/settings/inference/api.ts | 43 +++++ apps/web/src/settings/inference/index.ts | 3 + 4 files changed, 178 insertions(+), 41 deletions(-) diff --git a/apps/web/src/settings/credentials-api.ts b/apps/web/src/settings/credentials-api.ts index b5f2c0a64..8915f854b 100644 --- a/apps/web/src/settings/credentials-api.ts +++ b/apps/web/src/settings/credentials-api.ts @@ -91,10 +91,6 @@ export function deleteCredential(tenantId: string, credentialId: string): Promis export type UpdateCredentialInput = { readonly name?: string; readonly description?: string; - /** `baseURL`/`model` are this form's own convention for a local, - * Ollama-style credential — the stock route stores whatever object is - * sent here as opaque `metadata`, nothing more. */ - readonly metadata?: { readonly baseURL?: string; readonly model?: string }; }; export function updateCredential( diff --git a/apps/web/src/settings/credentials-section.tsx b/apps/web/src/settings/credentials-section.tsx index ca06d538d..fd4c36ae0 100644 --- a/apps/web/src/settings/credentials-section.tsx +++ b/apps/web/src/settings/credentials-section.tsx @@ -23,12 +23,24 @@ import { TableHeader, TableRow, } from "@corbits/react-ui"; +import { reportError } from "@corbits/error-sink"; import type { CredentialType } from "@intx/types"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { QueryView, toAPIQuery } from "@/lib/api-query"; -import { readProviderLogin, startProviderLogin } from "@/settings/inference"; +import { + listOwnModelProviders, + listOwnModels, + listOwnOfferings, + readProviderLogin, + repointOfferingModel, + startProviderLogin, + updateModelProviderBaseURL, + type ModelOfferingResponse, + type ModelProviderResponse, + type ModelResponse, +} from "@/settings/inference"; import { tenantKeys } from "@/query-client"; import { createCredential, @@ -43,6 +55,29 @@ import { } from "./credentials-api"; import { SETTINGS_STRINGS } from "./strings"; +type CatalogProvider = typeof ModelProviderResponse.infer; +type CatalogOffering = typeof ModelOfferingResponse.infer; + +/** The tenant-owned catalog provider a credential authenticates, if any — + * the row inference actually dials (base URL) and reads the model from, + * as opposed to the credential's own opaque metadata. */ +function linkedCatalogProvider( + credential: Credential, + catalogProviders: readonly CatalogProvider[], +): CatalogProvider | null { + return catalogProviders.find((provider) => provider.credentialId === credential.id) ?? null; +} + +/** The one offering this repo's connect flow (`shadowOffering`) mints per + * provider — first match is enough since the edit dialog only ever shows + * one model field. */ +function linkedCatalogOffering( + provider: CatalogProvider, + catalogOfferings: readonly CatalogOffering[], +): CatalogOffering | null { + return catalogOfferings.find((offering) => offering.providerId === provider.id) ?? null; +} + /** The registered OAuth provider a credential was minted by, as * `@corbits/oauth-core` records it; absent on a pasted key. */ function oauthProviderOf(credential: Credential): string | null { @@ -55,6 +90,9 @@ function oauthProviderOf(credential: Credential): string | null { type CredentialsData = { readonly credentials: readonly Credential[]; readonly providers: readonly Provider[]; + readonly catalogProviders: readonly CatalogProvider[]; + readonly catalogOfferings: readonly CatalogOffering[]; + readonly catalogModels: readonly (typeof ModelResponse.infer)[]; }; export function CredentialsSection({ tenantId }: { readonly tenantId: string | null }) { @@ -68,21 +106,40 @@ export function CredentialsSection({ tenantId }: { readonly tenantId: string | n const result = useQuery({ queryKey: tenantKeys.credentials(tenantId ?? "none"), queryFn: async (): Promise => { - if (tenantId === null) return { credentials: [], providers: [] }; - const [credentials, providers] = await Promise.all([ - listCredentials(tenantId), - listProviders(tenantId), - ]); - return { credentials, providers }; + if (tenantId === null) { + return { + credentials: [], + providers: [], + catalogProviders: [], + catalogOfferings: [], + catalogModels: [], + }; + } + const [credentials, providers, catalogProviders, catalogOfferings, catalogModels] = + await Promise.all([ + listCredentials(tenantId), + listProviders(tenantId), + listOwnModelProviders(tenantId), + listOwnOfferings(tenantId), + listOwnModels(tenantId), + ]); + return { credentials, providers, catalogProviders, catalogOfferings, catalogModels }; }, enabled: tenantId !== null, }); const query = toAPIQuery(result); const providers = result.data?.providers ?? []; + const catalogProviders = result.data?.catalogProviders ?? []; + const catalogOfferings = result.data?.catalogOfferings ?? []; + const catalogModels = result.data?.catalogModels ?? []; function reload() { if (tenantId === null) return; void queryClient.invalidateQueries({ queryKey: tenantKeys.credentials(tenantId) }); + // The catalog rows this dialog can now rewrite are read by the + // Inference settings section and by chat's model resolution — both + // key off the resolved catalog, so a credential edit must bust it too. + void queryClient.invalidateQueries({ queryKey: ["tenant", tenantId, "settings-models"] }); } const create = useMutation({ @@ -142,7 +199,7 @@ export function CredentialsSection({ tenantId }: { readonly tenantId: string | n }); const update = useMutation({ - mutationFn: (input: { + mutationFn: async (input: { readonly credentialId: string; readonly name: string; readonly description: string; @@ -150,16 +207,37 @@ export function CredentialsSection({ tenantId }: { readonly tenantId: string | n readonly model: string; }) => { if (tenantId === null) throw new Error("no workbench selected"); - return updateCredential(tenantId, input.credentialId, { + await updateCredential(tenantId, input.credentialId, { name: input.name, description: input.description, - metadata: { baseURL: input.baseURL, model: input.model }, }); + const credential = result.data?.credentials.find((row) => row.id === input.credentialId); + const provider = + credential === undefined ? null : linkedCatalogProvider(credential, catalogProviders); + if (provider === null) return; + if (input.baseURL.length > 0 && input.baseURL !== provider.baseURL) { + await updateModelProviderBaseURL(tenantId, provider.id, input.baseURL); + } + const offering = linkedCatalogOffering(provider, catalogOfferings); + const currentModel = catalogModels.find((row) => row.id === offering?.modelId); + if ( + offering !== null && + input.model.length > 0 && + input.model !== currentModel?.canonicalName + ) { + await repointOfferingModel(tenantId, offering, input.model, input.model); + } }, onSuccess: () => { setEditing(null); reload(); }, + onError: (cause: unknown) => { + reportError(cause, { + operation: "settings.credentials.update", + tenantId: tenantId ?? "none", + }); + }, }); // The stock DELETE route this hits: `vendor/intx/hub-api/src/routes/ @@ -172,6 +250,16 @@ export function CredentialsSection({ tenantId }: { readonly tenantId: string | n onSuccess: reload, }); + const editingProvider = + editing === null ? null : linkedCatalogProvider(editing, catalogProviders); + const editingOffering = + editingProvider === null ? null : linkedCatalogOffering(editingProvider, catalogOfferings); + const editingModel = catalogModels.find((row) => row.id === editingOffering?.modelId) ?? null; + const editingLinkage: EditCredentialLinkage | null = + editingProvider === null + ? null + : { baseURL: editingProvider.baseURL, model: editingModel?.canonicalName ?? "" }; + if (tenantId === null) { return ( { if (!open) setEditing(null); }} @@ -409,22 +498,24 @@ type EditCredentialInput = { readonly model: string; }; -function readMetadataString(metadata: Credential["metadata"], key: string): string { - if (metadata === null || metadata === undefined) return ""; - const value = (metadata as Record)[key]; - return typeof value === "string" ? value : ""; -} +/** The catalog values this credential's linked provider/offering carry + * today, or `null` when the credential has no linked provider — the dialog + * shows base URL/model fields only in the linked case. */ +type EditCredentialLinkage = { + readonly baseURL: string; + readonly model: string; +}; -// `Base URL`/`Model` are this form's own convention, round-tripped -// through the credential's opaque `metadata` field. function EditCredentialDialog({ credential, + linked, onOpenChange, onSave, submitting, error = null, }: { readonly credential: Credential | null; + readonly linked: EditCredentialLinkage | null; readonly onOpenChange: (open: boolean) => void; readonly onSave: (input: EditCredentialInput) => void; readonly submitting: boolean; @@ -442,8 +533,8 @@ function EditCredentialDialog({ setLoadedFor(credential.id); setName(credential.name); setDescription(credential.description ?? ""); - setBaseURL(readMetadataString(credential.metadata, "baseURL")); - setModel(readMetadataString(credential.metadata, "model")); + setBaseURL(linked?.baseURL ?? ""); + setModel(linked?.model ?? ""); } const canSubmit = credential !== null && name.trim().length > 0; @@ -475,24 +566,28 @@ function EditCredentialDialog({ {SETTINGS_STRINGS.credentialsNameLabel} setName(event.target.value)} autoFocus /> - - + {linked !== null && ( + <> + + + + )} {error !== null && (

{error} diff --git a/apps/web/src/settings/inference/api.ts b/apps/web/src/settings/inference/api.ts index bc83be218..31fb4a8b5 100644 --- a/apps/web/src/settings/inference/api.ts +++ b/apps/web/src/settings/inference/api.ts @@ -44,6 +44,7 @@ import { ModelResponse, ProviderResponse, UpdateModelOffering, + UpdateModelProvider, paginatedSchema, } from "@intx/types"; @@ -236,6 +237,48 @@ export function updateOwnOffering( ); } +/** Patches a tenant-owned model-provider's base URL — the row inference + * actually dials, distinct from anything a credential's own metadata says. */ +export function updateModelProviderBaseURL( + tenantId: string, + providerId: string, + baseURL: string, + fetchImpl: FetchImpl = fetch, +): Promise { + return request( + `/api/tenants/${tenantId}/catalog/providers/${providerId}`, + ModelProviderResponse, + "updating that provider's base URL", + { method: "PATCH", body: JSON.stringify(UpdateModelProvider.assert({ baseURL })) }, + fetchImpl, + ); +} + +/** Repoints an offering at a different model tag. There is no `modelId` + * patch on the stock offering route (only priority/tags/capabilities/ + * disabled — see `vendor/intx/hub-api/src/routes/model-offerings.ts`), and + * a model's `canonicalName` is immutable once created, so changing "the + * model" means minting (or reusing) a model row for the new tag and + * swapping the offering for one that points at it, carrying over its + * priority so it keeps its place in resolution. */ +export async function repointOfferingModel( + tenantId: string, + offering: typeof ModelOfferingResponse.infer, + canonicalName: string, + modelDisplayName: string | null, + fetchImpl: FetchImpl = fetch, +): Promise { + const modelId = await ensureModel(tenantId, canonicalName, modelDisplayName, fetchImpl); + if (modelId === offering.modelId) return offering; + await requestVoid( + `/api/tenants/${tenantId}/catalog/offerings/${offering.id}`, + "retiring the offering's old model tag", + { method: "DELETE" }, + fetchImpl, + ); + return ensureOffering(tenantId, modelId, offering.providerId, offering.priority, fetchImpl); +} + /** The provider identity a credential and a catalog entry hang off. */ export type ProviderIdentity = { readonly providerName: string; diff --git a/apps/web/src/settings/inference/index.ts b/apps/web/src/settings/inference/index.ts index 52d5be861..2a663a595 100644 --- a/apps/web/src/settings/inference/index.ts +++ b/apps/web/src/settings/inference/index.ts @@ -4,11 +4,14 @@ export { listOwnModelProviders, listOwnModels, listOwnOfferings, + repointOfferingModel, shadowOffering, + updateModelProviderBaseURL, updateOwnOffering, } from "./api"; export { credentialNameFor, ensureProviderRow } from "./api"; export type { ModelInfo, ModelOfferingInfo, ProviderIdentity, ShadowOfferingInput } from "./api"; +export type { ModelOfferingResponse, ModelProviderResponse, ModelResponse } from "@intx/types"; export { cancelProviderLogin, readProviderLogin, From e865a539e57bc289d0115999c78a28ad48cf5405 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 14:49:26 -0700 Subject: [PATCH 3/4] test(web): cover the model-change redeploy sequence's mapping logic (CL-8591) --- ...est.ts => mint-offering-for-model.test.ts} | 30 +++++----- .../src/settings/myra-model-redeploy.test.ts | 59 +++++++++++++++++++ 2 files changed, 75 insertions(+), 14 deletions(-) rename apps/web/src/settings/inference/{repoint-offering-model.test.ts => mint-offering-for-model.test.ts} (76%) create mode 100644 apps/web/src/settings/myra-model-redeploy.test.ts diff --git a/apps/web/src/settings/inference/repoint-offering-model.test.ts b/apps/web/src/settings/inference/mint-offering-for-model.test.ts similarity index 76% rename from apps/web/src/settings/inference/repoint-offering-model.test.ts rename to apps/web/src/settings/inference/mint-offering-for-model.test.ts index 1e3ab9a54..b752c3c96 100644 --- a/apps/web/src/settings/inference/repoint-offering-model.test.ts +++ b/apps/web/src/settings/inference/mint-offering-for-model.test.ts @@ -1,13 +1,16 @@ -// `repointOfferingModel` is a non-obvious two-step (ensure model, then -// delete+recreate the offering) because the stock offering PATCH has no +// `mintOfferingForModel` is a non-obvious two-step (ensure a model row, +// then a fresh offering) because the stock offering PATCH has no // `modelId` field and a model's `canonicalName` is immutable — see -// api.ts's doc on the function. Covers the two cases that make it -// non-trivial: a same-name edit is a no-op (no DELETE/POST at all), and a -// real change carries the offering's priority over to the new row. +// api.ts's doc on the function. It deliberately never deletes the old +// offering itself (a deployed Myra run may still pin it); that is the +// caller's job once a redeploy moves off it. Covers the two cases that +// make the mint step non-trivial: a same-name edit is a no-op, and a real +// change carries the offering's priority over to the new row without +// touching the old one. import { afterEach, describe, expect, test } from "bun:test"; -import { repointOfferingModel } from "./api"; +import { mintOfferingForModel } from "./api"; const TENANT_ID = "tnt_1"; const NOW = "2026-01-01T00:00:00.000Z"; @@ -35,7 +38,7 @@ const OFFERING = { updatedAt: NOW, }; -describe("repointOfferingModel", () => { +describe("mintOfferingForModel", () => { test("is a no-op when the canonical name resolves to the offering's own model", async () => { const calls: { method: string; path: string }[] = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { @@ -68,13 +71,13 @@ describe("repointOfferingModel", () => { throw new Error(`unexpected call: ${method} ${path}`); }) as typeof fetch; - const result = await repointOfferingModel(TENANT_ID, OFFERING, "qwen2.5:14b", "qwen2.5:14b"); + const result = await mintOfferingForModel(TENANT_ID, OFFERING, "qwen2.5:14b", "qwen2.5:14b"); expect(result).toBe(OFFERING); expect(calls.some((call) => call.method === "DELETE")).toBe(false); }); - test("deletes the old offering and recreates it at the same priority for a new model", async () => { + test("mints a sibling offering at the same priority for a new model, without deleting the old one", async () => { const calls: { method: string; path: string }[] = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const method = init?.method ?? "GET"; @@ -94,9 +97,6 @@ describe("repointOfferingModel", () => { { status: 201 }, ); } - if (method === "DELETE" && path.endsWith(`/catalog/offerings/${OFFERING.id}`)) { - return new Response(null, { status: 204 }); - } if (method === "POST" && path.endsWith("/catalog/offerings")) { const body = JSON.parse(String(init?.body)) as { modelId: string; priority: number }; return new Response( @@ -112,10 +112,12 @@ describe("repointOfferingModel", () => { throw new Error(`unexpected call: ${method} ${path}`); }) as typeof fetch; - const result = await repointOfferingModel(TENANT_ID, OFFERING, "qwen2.5:32b", "qwen2.5:32b"); + const result = await mintOfferingForModel(TENANT_ID, OFFERING, "qwen2.5:32b", "qwen2.5:32b"); + expect(result.id).toBe("offering_2"); expect(result.modelId).toBe("model_2"); expect(result.priority).toBe(OFFERING.priority); - expect(calls.map((call) => call.method)).toEqual(["POST", "DELETE", "POST"]); + expect(calls.map((call) => call.method)).toEqual(["POST", "POST"]); + expect(calls.some((call) => call.method === "DELETE")).toBe(false); }); }); diff --git a/apps/web/src/settings/myra-model-redeploy.test.ts b/apps/web/src/settings/myra-model-redeploy.test.ts new file mode 100644 index 000000000..0d8c9788b --- /dev/null +++ b/apps/web/src/settings/myra-model-redeploy.test.ts @@ -0,0 +1,59 @@ +// The non-obvious core of a model-change redeploy: replacing exactly the +// old offering id (and its declared source) in Myra's existing fallback +// chain, preserving order, every other source, and which slot was +// default — or reporting there is nothing to swap at all. + +import { describe, expect, test } from "bun:test"; + +import { swapDeclaredOffering } from "./myra-model-redeploy"; + +const BEFORE = { + sourceOfferingIds: ["off_a", "off_b", "off_c"], + defaultSourceOfferingId: "off_b", + declaredSources: [ + { provider: "anthropic" as const, model: "claude-sonnet-5" }, + { provider: "openai-compatible" as const, model: "qwen2.5:14b" }, + { provider: "openai" as const, model: "gpt-5" }, + ], +}; + +describe("swapDeclaredOffering", () => { + test("replaces the old id in place and its declared source at the same position", () => { + const result = swapDeclaredOffering( + BEFORE, + "off_b", + "off_new", + "openai-compatible", + "qwen2.5:32b", + ); + + expect(result).toEqual({ + sourceOfferingIds: ["off_a", "off_new", "off_c"], + defaultSourceOfferingId: "off_new", + declaredSources: [ + { provider: "anthropic", model: "claude-sonnet-5" }, + { provider: "openai-compatible", model: "qwen2.5:32b" }, + { provider: "openai", model: "gpt-5" }, + ], + }); + }); + + test("leaves the default offering id untouched when a non-default offering is swapped", () => { + const result = swapDeclaredOffering(BEFORE, "off_a", "off_new", "anthropic", "claude-opus-5"); + + expect(result?.defaultSourceOfferingId).toBe("off_b"); + expect(result?.sourceOfferingIds).toEqual(["off_new", "off_b", "off_c"]); + }); + + test("returns null when the old offering id isn't declared at all", () => { + const result = swapDeclaredOffering( + BEFORE, + "off_missing", + "off_new", + "anthropic", + "claude-opus-5", + ); + + expect(result).toBeNull(); + }); +}); From 8a821366038f1f1480763075909c7c22223a89e9 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 14:49:37 -0700 Subject: [PATCH 4/4] fix(web): a credential's model change redeploys Myra before retiring the old offering (CL-8591) --- .../src/onboarding/provider-connect-step.tsx | 7 +- apps/web/src/settings/credentials-section.tsx | 28 +++- apps/web/src/settings/inference/api.ts | 37 +++-- apps/web/src/settings/inference/index.ts | 3 +- apps/web/src/settings/myra-model-redeploy.ts | 126 ++++++++++++++++++ apps/web/src/settings/strings.ts | 1 + 6 files changed, 186 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/settings/myra-model-redeploy.ts diff --git a/apps/web/src/onboarding/provider-connect-step.tsx b/apps/web/src/onboarding/provider-connect-step.tsx index fc10b66d9..c74908ece 100644 --- a/apps/web/src/onboarding/provider-connect-step.tsx +++ b/apps/web/src/onboarding/provider-connect-step.tsx @@ -133,8 +133,11 @@ export type ExistingOffering = { }; /** Every visible offering becomes a source; the lowest priority is the default. */ -export async function resolveExistingOffering(tenantId: string): Promise { - const models = await getResolvedCatalog(tenantId); +export async function resolveExistingOffering( + tenantId: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const models = await getResolvedCatalog(tenantId, fetchImpl); const offerings = models .flatMap((model) => model.offerings.map((offering) => ({ ...offering, model: model.canonicalName })), diff --git a/apps/web/src/settings/credentials-section.tsx b/apps/web/src/settings/credentials-section.tsx index fd4c36ae0..cbff72728 100644 --- a/apps/web/src/settings/credentials-section.tsx +++ b/apps/web/src/settings/credentials-section.tsx @@ -30,17 +30,19 @@ import { useState } from "react"; import { QueryView, toAPIQuery } from "@/lib/api-query"; import { + deleteOwnOffering, listOwnModelProviders, listOwnModels, listOwnOfferings, + mintOfferingForModel, readProviderLogin, - repointOfferingModel, startProviderLogin, updateModelProviderBaseURL, type ModelOfferingResponse, type ModelProviderResponse, type ModelResponse, } from "@/settings/inference"; +import { redeployMyraForModelChange } from "@/settings/myra-model-redeploy"; import { tenantKeys } from "@/query-client"; import { createCredential, @@ -225,7 +227,26 @@ export function CredentialsSection({ tenantId }: { readonly tenantId: string | n input.model.length > 0 && input.model !== currentModel?.canonicalName ) { - await repointOfferingModel(tenantId, offering, input.model, input.model); + const newOffering = await mintOfferingForModel( + tenantId, + offering, + input.model, + input.model, + ); + if (newOffering.id !== offering.id) { + // Myra's own deployed run pins the old offering id — moving her onto + // the new one first, then retiring the old one, is the only order + // that never leaves a live run pointed at a dead offering. A failed + // redeploy throws here and both offerings are left in place. + await redeployMyraForModelChange({ + tenantId, + oldOfferingId: offering.id, + newOfferingId: newOffering.id, + provider: provider.plugin, + newCanonicalName: input.model, + }); + await deleteOwnOffering(tenantId, offering.id); + } } }, onSuccess: () => { @@ -585,6 +606,9 @@ function EditCredentialDialog({ onChange={(event) => setModel(event.target.value)} placeholder="qwen2.5:14b" /> + + {SETTINGS_STRINGS.credentialsModelChangeNotice} + )} diff --git a/apps/web/src/settings/inference/api.ts b/apps/web/src/settings/inference/api.ts index 31fb4a8b5..da228f8ae 100644 --- a/apps/web/src/settings/inference/api.ts +++ b/apps/web/src/settings/inference/api.ts @@ -254,14 +254,20 @@ export function updateModelProviderBaseURL( ); } -/** Repoints an offering at a different model tag. There is no `modelId` - * patch on the stock offering route (only priority/tags/capabilities/ - * disabled — see `vendor/intx/hub-api/src/routes/model-offerings.ts`), and - * a model's `canonicalName` is immutable once created, so changing "the - * model" means minting (or reusing) a model row for the new tag and - * swapping the offering for one that points at it, carrying over its - * priority so it keeps its place in resolution. */ -export async function repointOfferingModel( +/** Mints (or reuses) a sibling offering for a different model tag on the + * same provider, at the same priority — the first half of a model change. + * There is no `modelId` patch on the stock offering route (only priority/ + * tags/capabilities/disabled — see `vendor/intx/hub-api/src/routes/ + * model-offerings.ts`), and a model's `canonicalName` is immutable once + * created, so "changing the model" means a new offering row, not an edit + * of the old one. The old offering is deliberately left alive here: a + * deployed Myra run pins `sourceOfferingIds` in its stock launch spec + * (`vendor/intx/db/src/schema/workflow-run-launch-spec.ts`) and the + * allocation service re-resolves by those exact ids, so deleting the old + * offering before Myra is redeployed onto the new one would leave a live + * run pointing at a dead offering. The caller redeploys first, then calls + * {@link deleteOwnOffering} on the old id only once that succeeds. */ +export async function mintOfferingForModel( tenantId: string, offering: typeof ModelOfferingResponse.infer, canonicalName: string, @@ -270,13 +276,22 @@ export async function repointOfferingModel( ): Promise { const modelId = await ensureModel(tenantId, canonicalName, modelDisplayName, fetchImpl); if (modelId === offering.modelId) return offering; - await requestVoid( - `/api/tenants/${tenantId}/catalog/offerings/${offering.id}`, + return ensureOffering(tenantId, modelId, offering.providerId, offering.priority, fetchImpl); +} + +/** Deletes a tenant-owned offering — only ever called once nothing still + * declares it (see {@link mintOfferingForModel}'s doc). */ +export function deleteOwnOffering( + tenantId: string, + offeringId: string, + fetchImpl: FetchImpl = fetch, +): Promise { + return requestVoid( + `/api/tenants/${tenantId}/catalog/offerings/${offeringId}`, "retiring the offering's old model tag", { method: "DELETE" }, fetchImpl, ); - return ensureOffering(tenantId, modelId, offering.providerId, offering.priority, fetchImpl); } /** The provider identity a credential and a catalog entry hang off. */ diff --git a/apps/web/src/settings/inference/index.ts b/apps/web/src/settings/inference/index.ts index 2a663a595..b1ff1a549 100644 --- a/apps/web/src/settings/inference/index.ts +++ b/apps/web/src/settings/inference/index.ts @@ -3,8 +3,9 @@ export { getResolvedCatalog, listOwnModelProviders, listOwnModels, + deleteOwnOffering, listOwnOfferings, - repointOfferingModel, + mintOfferingForModel, shadowOffering, updateModelProviderBaseURL, updateOwnOffering, diff --git a/apps/web/src/settings/myra-model-redeploy.ts b/apps/web/src/settings/myra-model-redeploy.ts new file mode 100644 index 000000000..9d47e65e9 --- /dev/null +++ b/apps/web/src/settings/myra-model-redeploy.ts @@ -0,0 +1,126 @@ +// A credential edit that changes "the model" swaps in a new offering +// (`mintOfferingForModel`) while the old one stays alive — a deployed +// Myra run pins `sourceOfferingIds` in its stock launch spec and the +// allocation service re-resolves by those exact ids, so the old offering +// can only be retired once a redeploy has moved Myra onto the new one. +// This is the same stock deploy path the client runs at onboarding +// (`myra-deploy.ts`'s `deployMyraSource`, `POST /workflows/deployments`), +// just re-run here with one offering id swapped for another. + +import { type } from "arktype"; + +import { deployMyraSource } from "@/myra-deploy"; +import { resolveExistingOffering, type DeclaredSource } from "@/onboarding/provider-connect-step"; +import type { ModelProviderPlugin } from "@intx/types"; + +export class MyraRedeployError extends Error {} + +const TenantDomainShape = type({ domain: "string" }); + +async function readErrorBody(response: Response): Promise { + const body: unknown = await response.json().catch(() => undefined); + const envelope = type({ + error: { code: "string", userMessage: "string", refId: "string" }, + })(body); + return envelope instanceof type.errors ? `HTTP ${response.status}` : envelope.error.userMessage; +} + +async function resolveTenantDomain(tenantId: string, fetchImpl: typeof fetch): Promise { + const response = await fetchImpl(`/api/tenants/${encodeURIComponent(tenantId)}`); + if (!response.ok) { + throw new MyraRedeployError( + `resolving this workbench's domain failed: ${await readErrorBody(response)}`, + ); + } + const parsed = TenantDomainShape(await response.json()); + if (parsed instanceof type.errors) { + throw new MyraRedeployError(`this workbench came back an unexpected shape: ${parsed.summary}`); + } + return parsed.domain; +} + +export type RedeployForModelChangeInput = { + readonly tenantId: string; + readonly oldOfferingId: string; + readonly newOfferingId: string; + readonly provider: ModelProviderPlugin; + readonly newCanonicalName: string; +}; + +type ExistingOffering = Awaited>; + +/** The pure swap at this module's center: everything Myra already + * declares, with exactly the old offering id (and its declared source) + * replaced by the new one, order and every other source untouched. `null` + * when the old offering id isn't declared at all — nothing pins it, so + * there is nothing to swap. */ +export function swapDeclaredOffering( + before: NonNullable, + oldOfferingId: string, + newOfferingId: string, + provider: ModelProviderPlugin, + newCanonicalName: string, +): { + sourceOfferingIds: readonly string[]; + defaultSourceOfferingId: string; + declaredSources: readonly DeclaredSource[]; +} | null { + const index = before.sourceOfferingIds.indexOf(oldOfferingId); + if (index === -1) return null; + return { + sourceOfferingIds: before.sourceOfferingIds.map((id) => + id === oldOfferingId ? newOfferingId : id, + ), + defaultSourceOfferingId: + before.defaultSourceOfferingId === oldOfferingId + ? newOfferingId + : before.defaultSourceOfferingId, + declaredSources: before.declaredSources.map((source, position) => + position === index ? { provider, model: newCanonicalName } : source, + ), + }; +} + +/** + * Redeploys Myra so her declared sources point at `newOfferingId` instead + * of `oldOfferingId`, everything else in the fallback chain unchanged. + * Returns `false` (does nothing) when Myra isn't declaring the old + * offering at all — nothing pins it, so the caller can retire it directly + * — and throws, changing nothing about which offering is declared, if the + * redeploy itself fails; the caller must leave both offerings in that case. + */ +export async function redeployMyraForModelChange( + input: RedeployForModelChangeInput, + fetchImpl: typeof fetch = fetch, +): Promise { + const before = await resolveExistingOffering(input.tenantId, fetchImpl); + if (before === null) return false; + const swapped = swapDeclaredOffering( + before, + input.oldOfferingId, + input.newOfferingId, + input.provider, + input.newCanonicalName, + ); + if (swapped === null) return false; + + const tenantDomain = await resolveTenantDomain(input.tenantId, fetchImpl); + const deploy = await deployMyraSource( + { tenantId: input.tenantId, tenantDomain, ...swapped }, + fetchImpl, + ); + const deployed = await fetchImpl( + `/api/tenants/${encodeURIComponent(input.tenantId)}/workflows/deployments`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(deploy), + }, + ); + if (!deployed.ok) { + throw new MyraRedeployError( + `redeploying Myra onto the new model failed: ${await readErrorBody(deployed)}`, + ); + } + return true; +} diff --git a/apps/web/src/settings/strings.ts b/apps/web/src/settings/strings.ts index cf60438c8..f5f644e70 100644 --- a/apps/web/src/settings/strings.ts +++ b/apps/web/src/settings/strings.ts @@ -174,6 +174,7 @@ export const SETTINGS_STRINGS = { credentialsEditDialogTitle: "Edit credential", credentialsEditDialogDescription: "Base URL and model apply to a local, Ollama-style credential; leave them blank otherwise.", + credentialsModelChangeNotice: "Changing the model restarts Myra onto it.", credentialsBaseUrlLabel: "Base URL", credentialsModelLabel: "Model", credentialsEditSubmit: "Save",