diff --git a/apps/hub/src/app.ts b/apps/hub/src/app.ts index 689919ee..e50b9b3f 100644 --- a/apps/hub/src/app.ts +++ b/apps/hub/src/app.ts @@ -85,7 +85,10 @@ import { correlationMiddleware, requestContext, } from "./middleware/correlation.ts"; -import { createAvailabilityRoutes } from "./availability.ts"; +import { + createAvailabilityProbe, + createAvailabilityRoutes, +} from "./availability.ts"; import { createGitTokenBoundary } from "./git-token-boundary.ts"; import { createSidecarWebSocket } from "./sidecar-websocket.ts"; import { createCoordinatorCredentialRefresh } from "./auth/coordinator-credential-refresh.ts"; @@ -318,6 +321,10 @@ export async function createHubRuntime( hubId: hexEncode(key.publicKey).slice(0, 24), }); const allocation = workflows.services.workflowAllocationService; + const availabilityProbe = createAvailabilityProbe( + handle.db, + credentialCipher, + ); const coordinatorStartup = createInstalledCoordinatorStartup( handle.db, assetService, @@ -355,13 +362,14 @@ export async function createHubRuntime( }, coordinatorStartTimeoutMs, workflows.services.workflowLifecycleService.requestCancellation, + availabilityProbe, ); const coordinatorUpgrades = createCoordinatorUpgrades({ db: handle.db, runs: createWorkflowRunReader(agentRepoStore.repoStore), installed: (actor, projectId) => installForTenant( - { db: handle.db, assets: assetService }, + { db: handle.db, assets: assetService, probe: availabilityProbe }, actor, projectId, ), @@ -525,6 +533,7 @@ export async function createHubRuntime( liveApps, workflows.services.workflowLifecycleService.requestCancellation, agentRepoStore.repoStore, + availabilityProbe, ), ); app.route( diff --git a/apps/hub/src/artifacts/activity.ts b/apps/hub/src/artifacts/activity.ts index 3e52765f..297b0b1f 100644 --- a/apps/hub/src/artifacts/activity.ts +++ b/apps/hub/src/artifacts/activity.ts @@ -108,12 +108,22 @@ export function createAgentActivity(events: Pick) { delete run.said; return; } - case "message.run.ended": { + case "message.run.ended": + case "reactor.done": + case "reactor.error": { const run = entry(local); run.working = false; run.since = now; return; } + case "inference.error": { + if (event.data.error.category === "credential_failure") { + const run = entry(local); + run.working = false; + run.since = now; + } + return; + } case "tool.start": { const run = entry(local); // A turn whose start the hub missed, as after its own restart, dates from this call. diff --git a/apps/hub/src/auth/coordinator-credential-refresh.ts b/apps/hub/src/auth/coordinator-credential-refresh.ts index 0ebbb07b..c459a047 100644 --- a/apps/hub/src/auth/coordinator-credential-refresh.ts +++ b/apps/hub/src/auth/coordinator-credential-refresh.ts @@ -1,6 +1,9 @@ -import { and, eq } from "drizzle-orm"; -import type { DB } from "@intx/db"; -import { workflowRunExecutability } from "@intx/db"; +import { and, inArray } from "drizzle-orm"; +import { + getDescendantTenants, + workflowRunExecutability, + type DB, +} from "@intx/db"; import { workflowRun as runTable } from "@intx/db/schema"; import { pushCredentialReconcile, @@ -11,11 +14,10 @@ import type { CredentialCipher } from "@intx/types"; import type { MiddlewareHandler } from "hono"; import type { TenantEnv } from "@intx/hub-api"; import { getLogger } from "@intx/log"; -import { projectCoordinator } from "../db/schema.ts"; const log = getLogger(["corbits-builder", "coordinator-credentials"]); -// After a credential's secret changes, pushes it to every active coordinator run that uses it. +// After a credential's secret changes, pushes it to every active project run that uses it. export function createCoordinatorCredentialRefresh(options: { db: DB["db"]; router: SidecarRouter; @@ -29,19 +31,20 @@ export function createCoordinatorCredentialRefresh(options: { const body = await c.req.json(); if (body.secret === undefined || body.status === "revoked") return; const credentialId = c.req.param("credentialId"); - if (!credentialId) return; - const bindings = await options.db + const tenantId = c.req.param("tenantId"); + if (!credentialId || !tenantId) return; + const tenants = await getDescendantTenants(options.db, tenantId); + const runs = await options.db .select({ run: runTable }) - .from(projectCoordinator) - .innerJoin(runTable, eq(runTable.id, projectCoordinator.runId)) + .from(runTable) .where( and( - eq(projectCoordinator.active, true), - eq(runTable.tenantId, c.req.param("tenantId")!), + inArray(runTable.tenantId, tenants.length ? tenants : [tenantId]), + inArray(runTable.status, ["deployed", "running"]), ), ); await Promise.all( - bindings + runs .filter( ({ run }) => run.address && diff --git a/apps/hub/src/config/models.test.ts b/apps/hub/src/config/models.test.ts index 6d8aa8db..ad19501a 100644 --- a/apps/hub/src/config/models.test.ts +++ b/apps/hub/src/config/models.test.ts @@ -52,6 +52,11 @@ test("a project runs on the only connected provider's profile, a chosen one, or ).toEqual({ refused: "model_profile_required", }); + expect( + resolveModelChoice(MODEL_PROVIDERS, { profile: "anthropic_default" }, []), + ).toEqual({ + refused: "model_provider_missing", + }); expect( frozenModelChoice(MODEL_PROVIDERS, { profile: "openai_default" }, [ "anthropic", diff --git a/apps/hub/src/config/models.ts b/apps/hub/src/config/models.ts index 44f2c312..2ada4d51 100644 --- a/apps/hub/src/config/models.ts +++ b/apps/hub/src/config/models.ts @@ -467,9 +467,9 @@ export const MODEL_CHOICE_REFUSALS: Readonly< }; /** - * The profile a project runs on: the person's choice when this server can run it, else the shipped - * profile of the only connected provider. With none connected, or several and no runnable choice, - * there is no profile. + * The profile a project runs on: the person's choice when this server can run it on a connected + * provider, else the shipped profile of the only connected provider. With none connected, or + * several and no runnable choice, there is no profile. */ export function resolveModelChoice( directory: ModelDirectory, diff --git a/apps/hub/src/projects/coordinator-startup.ts b/apps/hub/src/projects/coordinator-startup.ts index 514aafe4..d36ca7f9 100644 --- a/apps/hub/src/projects/coordinator-startup.ts +++ b/apps/hub/src/projects/coordinator-startup.ts @@ -12,6 +12,7 @@ import { import type { InstalledWorkflows } from "@corbits-builder/schemas/installed-workflows"; import type { NativeSpecialistDeployment } from "@corbits-builder/schemas/native-specialist"; import { createCoordinatorRegistry } from "../auth/coordinator-registry.ts"; +import type { AvailabilityState } from "../availability.ts"; import { COORDINATOR_CAPACITY_FULL } from "../config/coordinator-start.ts"; import { installForTenant, @@ -265,11 +266,12 @@ export function createInstalledCoordinatorStartup( }) => Promise<{ runId: string }>, readyWaitMs: number, cancel: CancelRun, + probe?: (tenantId: string, providerId: string) => Promise, ) { const registry = createCoordinatorRegistry(db); return createProjectCoordinatorStartup({ installed(actor, projectId) { - return installForTenant({ db, assets }, actor, projectId); + return installForTenant({ db, assets, probe }, actor, projectId); }, async boundRun(tenantId, projectId) { const [row] = await db diff --git a/apps/hub/src/projects/coordinator-upgrade.ts b/apps/hub/src/projects/coordinator-upgrade.ts index d631c195..2b2721a2 100644 --- a/apps/hub/src/projects/coordinator-upgrade.ts +++ b/apps/hub/src/projects/coordinator-upgrade.ts @@ -3,6 +3,8 @@ import { type } from "arktype"; import type { DB } from "@intx/db"; import { approval, + modelOffering, + modelProvider, workflowRun, workflowRunDispatch, workflowRunLaunchSpec, @@ -162,6 +164,7 @@ export function createCoordinatorUpgrades(deps: { status: workflowRun.status, cancellationRequestedAt: workflowRun.cancellationRequestedAt, cancellationReason: workflowRun.cancellationReason, + credentialRefs: workflowRun.credentialRefs, principalId: workflowRunLaunchSpec.sourceAuthorityPrincipalId, bundle: workflowRunLaunchSpec.frozenApprovalBundle, }) @@ -196,8 +199,18 @@ export function createCoordinatorUpgrades(deps: { !["deployed", "running"].includes(latest.status) ) return; - const current = (await deps.installed(actor, projectId)).coordinator.pin; - if (pinOf(latest.bundle) === current) return; + const current = (await deps.installed(actor, projectId)).coordinator; + const [offering] = await db + .select({ credentialId: modelProvider.credentialId }) + .from(modelOffering) + .innerJoin(modelProvider, eq(modelProvider.id, modelOffering.providerId)) + .where(eq(modelOffering.id, current.defaultSourceOfferingId)); + const sameCredential = + !offering?.credentialId || + Boolean( + latest.credentialRefs?.credentialIds.includes(offering.credentialId), + ); + if (pinOf(latest.bundle) === current.pin && sameCredential) return; const parked = await atSafePoint(actor, { runId: latest.runId, address: latest.address, diff --git a/apps/hub/src/projects/model-choice.ts b/apps/hub/src/projects/model-choice.ts index d833ec1d..e0509ad2 100644 --- a/apps/hub/src/projects/model-choice.ts +++ b/apps/hub/src/projects/model-choice.ts @@ -2,9 +2,11 @@ import { and, eq } from "drizzle-orm"; import { listVisibleProviders, type DB } from "@intx/db"; import { principal, tenant } from "@intx/db/schema"; import type { ModelChoice } from "@corbits-builder/schemas/models"; +import type { AvailabilityState } from "../availability.ts"; import { MODEL_CHOICE_REFUSALS, frozenModelChoice, + pickedModelChoice, providerOfRow, resolveModelChoice, type ModelDirectory, @@ -19,16 +21,28 @@ import { userPrincipalIn } from "./authorization.ts"; import { ProjectError } from "./errors.ts"; import type { Actor } from "../lib/actor.ts"; -/** The provider entries a tenant can run on: each enabled connection with a credential. */ +/** The provider entries a tenant can run on: each enabled connection with a non-refused credential. */ export async function connectedProviders( db: DB["db"], directory: ModelDirectory, tenantId: string, + probe?: (tenantId: string, providerId: string) => Promise, ): Promise { - const ids = (await listVisibleProviders(db, tenantId)) - .filter(({ row }) => row.credentialId !== null && !row.disabled) - .map(({ row }) => providerOfRow(directory, row)?.id) - .filter((id) => id !== undefined); + const visible = (await listVisibleProviders(db, tenantId)).filter( + ({ row }) => row.credentialId !== null && !row.disabled, + ); + const ids: string[] = []; + for (const { row } of visible) { + const entry = providerOfRow(directory, row); + if (!entry) continue; + if ( + probe && + entry.kind !== "custom" && + (await probe(tenantId, row.id)) === "refused" + ) + continue; + ids.push(entry.id); + } return [...new Set(ids)]; } @@ -62,9 +76,15 @@ export async function projectModelChoice( actor: Actor, projectId: string, directory: ModelDirectory, + probe?: (tenantId: string, providerId: string) => Promise, ): Promise { const { models } = await loadSettings(db, actor.tenantId, projectId); - const connected = await connectedProviders(db, directory, actor.tenantId); + const connected = await connectedProviders( + db, + directory, + actor.tenantId, + probe, + ); const kept = frozenModelChoice(directory, models.value, connected); if (kept) return kept; const resolved = resolveModelChoice( @@ -94,3 +114,49 @@ export async function projectModelChoice( ); return resolved.choice; } + +/** Switches a project onto one of the caller's runnable profiles. */ +export async function switchProjectModelChoice( + db: DB["db"], + actor: Actor, + projectId: string, + profile: string, + directory: ModelDirectory, + probe?: (tenantId: string, providerId: string) => Promise, +): Promise { + const { models } = await loadSettings(db, actor.tenantId, projectId); + const personal = await personalChoice(db, actor); + const picked = + pickedModelChoice(personal, profile) ?? + pickedModelChoice(models.value, profile); + if (!picked) + throw new ProjectError(400, "model_profile_unknown", { profile }); + const connected = await connectedProviders( + db, + directory, + actor.tenantId, + probe, + ); + const frozen = frozenModelChoice(directory, picked, connected); + if (!frozen) + throw new ProjectError(409, "model_provider_missing", { + message: MODEL_CHOICE_REFUSALS.model_provider_missing, + }); + const scopeKey = `project:${projectId}`; + const fields = await loadSettingsAt(db, actor.tenantId, scopeKey); + await db + .update(settingsRecord) + .set({ + fields: { + ...fields, + models: { value: frozen, version: fields.models.version + 1 }, + }, + }) + .where( + and( + eq(settingsRecord.tenantId, actor.tenantId), + eq(settingsRecord.scopeKey, scopeKey), + ), + ); + return frozen; +} diff --git a/apps/hub/src/projects/routes.ts b/apps/hub/src/projects/routes.ts index 4c2793e8..a85adda7 100644 --- a/apps/hub/src/projects/routes.ts +++ b/apps/hub/src/projects/routes.ts @@ -31,10 +31,18 @@ import { readProjectImport } from "@corbits-builder/schemas/project-import"; import { createProjectService, ProjectError, + projectResource, type CreateTenant, type ProjectCaller, } from "./service.ts"; -import { actorOf, resolveProjectTenant } from "./authorization.ts"; +import { + actorOf, + grantAuthorization, + resolveProjectTenant, +} from "./authorization.ts"; +import { switchProjectModelChoice } from "./model-choice.ts"; +import { modelDirectory } from "../settings/custom-providers.ts"; +import type { AvailabilityState } from "../availability.ts"; import { createProjectNextStep, mountProjectNextStepRoutes, @@ -188,6 +196,7 @@ export function createProjectRoutes( reason: string, ) => Promise, repoStore?: RepoStore, + probe?: (tenantId: string, providerId: string) => Promise, ) { const app = new Hono(); const service = createProjectService(db); @@ -697,6 +706,28 @@ export function createProjectRoutes( const view = await service.read(actor, projectId); if (view.status !== "active") throw new ProjectError(409, "project_archived"); + const body = await readJson(c.req); + const profile = + body !== null && + typeof body === "object" && + "profile" in body && + typeof body.profile === "string" + ? body.profile.trim() + : undefined; + if (profile) { + const allowed = await grantAuthorization(db, actor); + if (!(await allowed(projectResource(projectId), "manage"))) + throw new ProjectError(403, "forbidden"); + const directory = await modelDirectory(db, actor.tenantId); + await switchProjectModelChoice( + db, + actor, + projectId, + profile, + directory, + probe, + ); + } await startCoordinator?.upgrade?.(actor, projectId); return c.json({ started: await resumeCoordinator(actor, projectId) }); } catch (error) { diff --git a/apps/hub/src/settings/routes.ts b/apps/hub/src/settings/routes.ts index 1c07efd1..f25f5f8e 100644 --- a/apps/hub/src/settings/routes.ts +++ b/apps/hub/src/settings/routes.ts @@ -53,7 +53,6 @@ import { updateCustomProvider, } from "./custom-providers.ts"; import { readJson } from "../lib/json.ts"; -import { connectedProviders } from "../projects/model-choice.ts"; import { createAvailabilityProbe, probeKey, @@ -105,8 +104,8 @@ export function createSettingsRoutes( nativeFetch: (request: Request) => Promise, ) { const app = new Hono(); - const service = createSettingsService(db); const probe = createAvailabilityProbe(db, cipher); + const service = createSettingsService(db, probe); const requireManage = async (c: Context) => { const allowed = await reportAuthorization(db, actor(c)); if (!(await allowed("sb-settings:tenant", "manage"))) @@ -289,14 +288,15 @@ export function createSettingsRoutes( ); const answering = new Set( connections - .filter((row) => row.status === "connected") + .filter( + (row): row is typeof row & { entry: string } => + row.entry !== undefined && + (row.status === "connected" || + (row.status === undefined && row.credentialSet)), + ) .map((row) => row.entry), ); - const automatic = resolveModelChoice( - directory, - null, - await connectedProviders(db, directory, tenantId), - ); + const automatic = resolveModelChoice(directory, null, [...answering]); return c.json({ providers: directory // A custom provider shows only to someone who may read the connection it runs on. diff --git a/apps/hub/src/settings/service.ts b/apps/hub/src/settings/service.ts index b1499f89..9a837429 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 type { AvailabilityState } from "../availability.ts"; import { connectedProviders } from "../projects/model-choice.ts"; import { modelDirectory } from "./custom-providers.ts"; import { digest } from "../lib/digest.ts"; @@ -41,7 +42,10 @@ const record = ( fields, }); -export function createSettingsService(db: DB["db"]) { +export function createSettingsService( + db: DB["db"], + probe?: (tenantId: string, providerId: string) => Promise, +) { async function authorize( actor: Actor, projectId: string | undefined, @@ -93,7 +97,12 @@ export function createSettingsService(db: DB["db"]) { const models = input.fields.models?.value; if (models) { const directory = await modelDirectory(db, actor.tenantId); - const connected = await connectedProviders(db, directory, actor.tenantId); + const connected = await connectedProviders( + db, + directory, + actor.tenantId, + probe, + ); const refused = invalidModelChoice(directory, models, connected); if (refused) throw new ProjectError(400, "unknown_model_choice", { diff --git a/apps/hub/src/workflows/install-for-tenant.ts b/apps/hub/src/workflows/install-for-tenant.ts index ea5f6423..662a1fb7 100644 --- a/apps/hub/src/workflows/install-for-tenant.ts +++ b/apps/hub/src/workflows/install-for-tenant.ts @@ -36,6 +36,7 @@ import { } from "../inference/oauth-login-quirks.ts"; import { projectModelChoice } from "../projects/model-choice.ts"; import { ProjectError } from "../projects/errors.ts"; +import type { AvailabilityState } from "../availability.ts"; import { buildWorkflowPackages } from "./build-packages.ts"; import { resolveRoleInference, type RoleInference } from "./role-inference.ts"; import type { Actor } from "../lib/actor.ts"; @@ -49,6 +50,9 @@ type InstallForTenantPorts = { AssetService, "createAsset" | "populateAsset" | "listAssetBlobs" >; + probe?: + | ((tenantId: string, providerId: string) => Promise) + | undefined; }; type Bindings = Record; @@ -422,7 +426,13 @@ export async function installForTenant( const directory = await modelDirectory(ports.db, actor.tenantId); const bindings = workflowBindings( directory, - await projectModelChoice(ports.db, actor, projectId, directory), + await projectModelChoice( + ports.db, + actor, + projectId, + directory, + ports.probe, + ), ); const built = await builtPackages( Object.fromEntries( diff --git a/apps/web/src/api/projects.ts b/apps/web/src/api/projects.ts index 4ab054ad..3059cb94 100644 --- a/apps/web/src/api/projects.ts +++ b/apps/web/src/api/projects.ts @@ -160,9 +160,15 @@ export async function shipProject( export async function startProjectCoordinator( projectId: string, + profile?: string, ): Promise { const tenantId = await workspaceTenantId(); - await productClient().startProjectCoordinator(tenantId, projectId); + await productClient().startProjectCoordinator( + tenantId, + projectId, + undefined, + profile === undefined ? undefined : { profile }, + ); } /** Renames through a `rename_project` mutation. */ diff --git a/apps/web/src/api/run-stream.ts b/apps/web/src/api/run-stream.ts index a7e5390d..38165cba 100644 --- a/apps/web/src/api/run-stream.ts +++ b/apps/web/src/api/run-stream.ts @@ -27,6 +27,11 @@ export type RunStreamEvent = { callId?: unknown; name?: unknown; arguments?: unknown; + error?: { + category?: unknown; + message?: unknown; + statusCode?: unknown; + }; }; }; diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts index db60e02b..9ba2c3a1 100644 --- a/apps/web/src/config.ts +++ b/apps/web/src/config.ts @@ -543,10 +543,30 @@ export const wording = { cancelled: "it was cancelled", timeout: "it ran out of time", "timed-out": "it ran out of time", + credential_failure: "the model provider refused its credential", } as Record, restart: "The coordinator can start it again.", details: "Details", }, + credentials: { + stopped: + "The model provider refused its API key or login, so this step could not finish.", + cause: "the model provider refused its credential", + bannerTitle: (provider?: string) => + provider === undefined + ? "Model credential refused" + : `${provider} refused its credential`, + bannerLead: (provider?: string) => + provider === undefined + ? "Re-add the provider's API key or switch to another connected profile to continue." + : `Re-add your ${provider} credential or switch to another connected profile to continue.`, + readdKey: (provider?: string) => + provider === undefined ? "Re-add provider key" : `Re-add ${provider} key`, + reconnect: (provider: string) => `Reconnect ${provider}`, + switchTo: (profile: string) => `Switch to ${profile}`, + switching: "Switching…", + continueMessage: "Continue from where we left off.", + }, /** A coordinator hand-off and what came of it, as one message in the conversation. */ dispatch: { /** What a call works on when its arguments name no document or concept. */ @@ -860,6 +880,8 @@ export const wording = { dialogLead: "Pick a provider, paste its key, and test it. It is saved once the test passes.", connectOne: (label: string) => `Connect ${label}`, + reconnect: (label: string) => `Reconnect ${label}`, + readdKey: (label: string) => `Re-add ${label} key`, keyLead: "Paste its API key and test it. It is saved once the test passes.", provider: "Provider", baseUrl: "Base URL", diff --git a/apps/web/src/pages/profile-select.test.tsx b/apps/web/src/pages/profile-select.test.tsx index c041d0cb..c7f4b40b 100644 --- a/apps/web/src/pages/profile-select.test.tsx +++ b/apps/web/src/pages/profile-select.test.tsx @@ -149,6 +149,104 @@ describe("NewProjectProfile", () => { })); expect(render(undefined, models)).toBe(""); }); + + test("falls back to an available profile and offers reconnect when the active profile's provider is refused", () => { + const models = catalog(); + models.providers = [ + { + id: "anthropic", + plugin: "anthropic", + label: "Anthropic", + hint: "Claude", + baseURL: "https://api.anthropic.com", + kind: "api_key", + models: [], + connections: [ + { + id: "prv_anthropic", + name: "Anthropic", + credentialSet: true, + status: "refused", + note: "HTTP 401", + }, + ], + }, + { + id: "openai", + plugin: "openai", + label: "OpenAI", + hint: "GPT", + baseURL: "https://api.openai.com/v1", + kind: "api_key", + models: [], + connections: [ + { + id: "prv_openai", + name: "OpenAI", + credentialSet: true, + status: "connected", + }, + ], + }, + ]; + models.profiles = [ + { + id: "anthropic_default", + label: "Claude", + hint: "HTTP 401", + tiers: tiers("anthropic"), + available: false, + }, + { + id: "openai_default", + label: "GPT", + hint: "OpenAI", + tiers: tiers("openai"), + available: true, + }, + ]; + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + client.setQueryData(keys.modelCatalog, models); + client.setQueryData(keys.modelChoice, { profile: "anthropic_default" }); + const html = renderToStaticMarkup( + + {}} /> + , + ); + expect(html).toContain("Model profile, GPT"); + expect(html).toContain("Re-add Anthropic key"); + }); + + test("offers reconnect when the only configured provider is refused", () => { + const models = catalog(); + models.providers = [ + { + id: "anthropic", + plugin: "anthropic", + label: "Anthropic", + hint: "Claude", + baseURL: "https://api.anthropic.com", + kind: "api_key", + models: [], + connections: [ + { + id: "prv_anthropic", + name: "Anthropic", + credentialSet: true, + status: "refused", + note: "HTTP 401", + }, + ], + }, + ]; + models.profiles = models.profiles.map((profile) => ({ + ...profile, + available: false, + })); + expect(render(undefined, models)).toContain("Re-add Anthropic key"); + }); }); describe("Profiles", () => { diff --git a/apps/web/src/pages/profile-select.tsx b/apps/web/src/pages/profile-select.tsx index d03fe621..9a146f82 100644 --- a/apps/web/src/pages/profile-select.tsx +++ b/apps/web/src/pages/profile-select.tsx @@ -14,13 +14,14 @@ import type { CustomProfile, ModelCatalog, ModelChoice, + ModelProvider, } from "@corbits-builder/schemas/models"; import { wording } from "../config.ts"; import { keys } from "../queries/keys.ts"; import { workspaceTenantId } from "../api/auth.ts"; import { productClient } from "../api/transport.ts"; import { available } from "./profiles.jsx"; -import { useModelCatalog } from "./providers.jsx"; +import { ReconnectProviderButton, useModelCatalog } from "./providers.jsx"; const copy = wording.profiles; @@ -42,7 +43,10 @@ function option( } /** The shipped profiles, then the person's, each runnable on a connected provider. */ -function profileOptions(catalog: ModelCatalog, choice: ModelChoice | null) { +export function profileOptions( + catalog: ModelCatalog, + choice: ModelChoice | null, +) { return [ ...catalog.profiles.filter((profile) => profile.available), ...(choice?.custom ?? []).filter((profile) => @@ -51,7 +55,7 @@ function profileOptions(catalog: ModelCatalog, choice: ModelChoice | null) { ].map((profile) => option(catalog, profile)); } -function useModelChoice() { +export function useModelChoice() { return useQuery({ queryKey: keys.modelChoice, queryFn: async () => @@ -60,6 +64,42 @@ function useModelChoice() { }); } +function isProviderRefused(provider: ModelProvider): boolean { + return ( + !provider.connections.some( + (connection) => connection.status === "connected", + ) && + provider.connections.some((connection) => connection.status === "refused") + ); +} + +/** The provider whose refused credential blocks the person's chosen profile, or any refused provider when none can run. */ +export function refusedProviderFor( + catalog: ModelCatalog | undefined, + choice: ModelChoice | null | undefined, + hasRunnableProfile: boolean, +): ModelProvider | undefined { + if (!catalog) return undefined; + const targetId = choice?.profile ?? catalog.automaticProfile; + if (targetId) { + const profile = + choice?.custom?.find((entry) => entry.id === targetId) ?? + catalog.profiles.find((entry) => entry.id === targetId); + if (profile) { + for (const binding of Object.values(profile.tiers)) { + const provider = catalog.providers.find( + (entry) => entry.id === binding.provider, + ); + if (provider && isProviderRefused(provider)) return provider; + } + } + } + if (!hasRunnableProfile) { + return catalog.providers.find(isProviderRefused); + } + return undefined; +} + /** Picks the profile a new project starts on, from the person's active one. */ export function NewProjectProfile({ value, @@ -75,16 +115,30 @@ export function NewProjectProfile({ const active = choice?.profile ?? catalog?.automaticProfile; const shown = models.find((model) => model.id === active)?.id ?? models[0]?.id; + const selected = models.find((model) => model.id === value)?.id ?? shown; + const refused = refusedProviderFor(catalog, choice, shown !== undefined); useEffect(() => { - if (value === undefined && shown !== undefined) onValueChange(shown); - }, [value, shown, onValueChange]); - if (shown === undefined) return null; + if (selected !== undefined && value !== selected) onValueChange(selected); + }, [value, selected, onValueChange]); + if (selected === undefined && refused === undefined) return null; return ( - + <> + {selected === undefined ? null : ( + + )} + {refused === undefined ? null : ( + { + if (active !== undefined) onValueChange(active); + }} + /> + )} + ); } diff --git a/apps/web/src/pages/providers.tsx b/apps/web/src/pages/providers.tsx index 2ab0aaaa..6e501ddd 100644 --- a/apps/web/src/pages/providers.tsx +++ b/apps/web/src/pages/providers.tsx @@ -198,6 +198,12 @@ export function ProvidersSection({ onChanged }: { onChanged: () => void }) { {copy.status[connection.status]} ) : null} + {connection.status === "refused" ? ( + + ) : null} ); })} @@ -236,6 +242,43 @@ export function ProvidersSection({ onChanged }: { onChanged: () => void }) { ); } +export function ReconnectProviderButton({ + provider, + onConnected, +}: { + provider: ModelProvider; + onConnected: () => void; +}) { + const [open, setOpen] = useState(false); + const { signingIn, signIn } = useSubscriptionLogins(onConnected); + if (provider.kind === "login") { + return ( + + ); + } + return ( + <> + + + + ); +} + /** The one call to action when nothing is connected: it opens the same dialog Settings uses. */ export function ConnectProvider({ onConnected, diff --git a/apps/web/src/pages/workspace/credential-recovery.tsx b/apps/web/src/pages/workspace/credential-recovery.tsx new file mode 100644 index 00000000..4ae94234 --- /dev/null +++ b/apps/web/src/pages/workspace/credential-recovery.tsx @@ -0,0 +1,255 @@ +import { useState } from "react"; +import { Button as UiButton } from "@corbits/react-ui"; +import type { + ModelCatalog, + ModelChoice, + ModelProvider, +} from "@corbits-builder/schemas/models"; +import type { FailedRun } from "@corbits-builder/schemas/artifact"; +import type { + ConversationEvent, + ConversationTurn, +} from "../../api/conversation.ts"; +import { wording } from "../../config.ts"; +import { profileOptions, refusedProviderFor } from "../profile-select.tsx"; +import { AddProviderDialog, useSubscriptionLogins } from "../providers.tsx"; +import { isCredentialFailureText } from "./visible-chat.ts"; + +export type CredentialRecoveryState = { + readonly at: number; + readonly retryMessage: string; + readonly provider?: ModelProvider; + readonly alternatives: readonly { + readonly id: string; + readonly label: string; + }[]; +}; + +function latestSuccessfulAssistantAt( + turns: readonly ConversationTurn[], +): number { + let latest = 0; + for (const turn of turns) { + if ( + turn.role === "assistant" && + !isCredentialFailureText(turn.text) && + turn.at > latest + ) { + latest = turn.at; + } + } + return latest; +} + +function latestCredentialFailureAt(input: { + readonly turns: readonly ConversationTurn[]; + readonly failed: readonly FailedRun[] | undefined; + readonly events: readonly ConversationEvent[] | null; + readonly liveCredentialFailed: boolean; + readonly liveEndedAt: number | null; +}): number | null { + let latest: number | null = null; + for (const turn of input.turns) { + if ( + isCredentialFailureText(turn.text) && + (latest === null || turn.at > latest) + ) { + latest = turn.at; + } + } + if (input.failed !== undefined) { + for (const run of input.failed) { + const message = run.error?.message; + if (message !== undefined && isCredentialFailureText(message)) { + const parsed = Date.parse(run.endedAt); + const at = Number.isNaN(parsed) ? 1 : parsed; + if (latest === null || at > latest) latest = at; + } + } + } + if (input.events !== null) { + for (const event of input.events) { + if (event.type === "StepFailed" || event.type === "RunFailed") { + const detail = event.body["message"]; + if (typeof detail === "string" && isCredentialFailureText(detail)) { + const at = event.seq; + if (latest === null || at > latest) latest = at; + } + } + } + } + if (input.liveCredentialFailed) { + const at = input.liveEndedAt ?? Date.now(); + if (latest === null || at > latest) latest = at; + } + return latest; +} + +function retryMessageFor( + turns: readonly ConversationTurn[], + failureAt: number, +): string { + for (let index = turns.length - 1; index >= 0; index -= 1) { + const turn = turns[index]!; + if (turn.role !== "person" || turn.at > failureAt) continue; + const text = turn.text.trim(); + if (text.length === 0) continue; + const answered = turns + .slice(index + 1) + .some( + (later) => + later.role === "assistant" && !isCredentialFailureText(later.text), + ); + if (!answered) return text; + break; + } + return wording.credentials.continueMessage; +} + +function resolveFailingProvider( + catalog: ModelCatalog | undefined, + choice: ModelChoice | null | undefined, +): ModelProvider | undefined { + if (!catalog) return undefined; + const refused = refusedProviderFor(catalog, choice, false); + if (refused) return refused; + const activeId = choice?.profile ?? catalog.automaticProfile; + if (activeId) { + const profile = + choice?.custom?.find((entry) => entry.id === activeId) ?? + catalog.profiles.find((entry) => entry.id === activeId); + if (profile) { + const provider = catalog.providers.find( + (entry) => entry.id === profile.tiers.medium.provider, + ); + if (provider) return provider; + } + } + return catalog.providers.find((provider) => + provider.connections.some((conn) => conn.credentialSet), + ); +} + +export function resolveCredentialRecovery(input: { + readonly turns: readonly ConversationTurn[]; + readonly failed: readonly FailedRun[] | undefined; + readonly events: readonly ConversationEvent[] | null; + readonly liveCredentialFailed: boolean; + readonly liveEndedAt: number | null; + readonly dismissedAt: number; + readonly catalog: ModelCatalog | undefined; + readonly choice: ModelChoice | null | undefined; +}): CredentialRecoveryState | null { + const refusedProvider = refusedProviderFor( + input.catalog, + input.choice, + false, + ); + const failureAt = latestCredentialFailureAt(input); + if (failureAt === null) return null; + if (failureAt <= latestSuccessfulAssistantAt(input.turns)) return null; + if (failureAt <= input.dismissedAt) return null; + const provider = + refusedProvider ?? resolveFailingProvider(input.catalog, input.choice); + const activeProfileId = + input.choice?.profile ?? input.catalog?.automaticProfile; + const alternatives: { readonly id: string; readonly label: string }[] = []; + if (input.catalog) { + for (const option of profileOptions(input.catalog, input.choice ?? null)) { + if (provider) { + const profile = + input.choice?.custom?.find((entry) => entry.id === option.id) ?? + input.catalog.profiles.find((entry) => entry.id === option.id); + if ( + profile && + Object.values(profile.tiers).some( + (binding) => binding.provider === provider.id, + ) + ) + continue; + } + if (option.id !== activeProfileId) { + alternatives.push({ id: option.id, label: option.name }); + } + } + } + let retryProvider: { readonly provider: ModelProvider } | undefined; + if (provider !== undefined) retryProvider = { provider }; + return { + at: failureAt, + retryMessage: retryMessageFor(input.turns, failureAt), + ...retryProvider, + alternatives, + }; +} + +export function CredentialRecoveryBanner({ + recovery, + busy, + onReconnected, + onSwitchProfile, +}: { + recovery: CredentialRecoveryState; + busy: boolean; + onReconnected: () => void; + onSwitchProfile: (profileId: string) => void; +}) { + const [dialogOpen, setDialogOpen] = useState(false); + const { signingIn, signIn } = useSubscriptionLogins(onReconnected); + const provider = recovery.provider; + const isLogin = provider?.kind === "login"; + + return ( +
+

+ {wording.credentials.bannerTitle(provider?.label)} +

+

{wording.credentials.bannerLead(provider?.label)}

+
+ {isLogin && provider ? ( + void signIn(provider)} + > + {signingIn === provider.id + ? wording.providers.waiting + : wording.credentials.reconnect(provider.label)} + + ) : ( + setDialogOpen(true)} + > + {wording.credentials.readdKey(provider?.label)} + + )} + {recovery.alternatives.map((alternative) => ( + onSwitchProfile(alternative.id)} + > + {busy + ? wording.credentials.switching + : wording.credentials.switchTo(alternative.label)} + + ))} +
+ {isLogin ? null : ( + + )} +
+ ); +} diff --git a/apps/web/src/pages/workspace/dispatch-card.tsx b/apps/web/src/pages/workspace/dispatch-card.tsx index d6d08a30..cc9e6579 100644 --- a/apps/web/src/pages/workspace/dispatch-card.tsx +++ b/apps/web/src/pages/workspace/dispatch-card.tsx @@ -12,6 +12,7 @@ import type { FailedRun, InFlightRun } from "@corbits-builder/schemas/artifact"; import { wording } from "../../config.ts"; import type { CallResult } from "./live-run.ts"; import type { ApprovalOutcome, DocumentApproval } from "./transcript.tsx"; +import { isCredentialFailureText } from "./visible-chat.ts"; const text = wording.dispatch; @@ -315,11 +316,19 @@ export function dispatchStatus( ? run.role === dispatch.role && after(run.endedAt) : run.runId === dispatch.runId, ); - if (failure !== undefined) + if (failure !== undefined) { + const raw = failure.error?.message; + let reason: string = text.failedUnknown; + if (raw !== undefined && isCredentialFailureText(raw)) { + reason = wording.credentials.cause; + } else if (raw !== undefined && raw.length > 0) { + reason = raw; + } return { state: "failed", - reason: failure.error?.message ?? text.failedUnknown, + reason, }; + } const run = room.inFlight.find((item) => dispatch.runId === undefined ? item.role === dispatch.role && diff --git a/apps/web/src/pages/workspace/index.tsx b/apps/web/src/pages/workspace/index.tsx index 731f56bd..67c0b13a 100644 --- a/apps/web/src/pages/workspace/index.tsx +++ b/apps/web/src/pages/workspace/index.tsx @@ -119,6 +119,12 @@ import { } from "./design-concepts.tsx"; import { useLiveAppPresence } from "./live-app.tsx"; import { NextStepLine } from "./next-step.tsx"; +import { + CredentialRecoveryBanner, + resolveCredentialRecovery, +} from "./credential-recovery.tsx"; +import { useModelChoice } from "../profile-select.tsx"; +import { useModelCatalog } from "../providers.tsx"; import { Dictated } from "../../dictation.jsx"; import { useShellReply } from "../../flourish.tsx"; @@ -381,6 +387,10 @@ export function StageWorkspace({ const [startFault, setStartFault] = useState(null); // Why the coordinator has no model to run on, which only the person can settle. const [modelNeeded, setModelNeeded] = useState(null); + const [dismissedCredentialAt, setDismissedCredentialAt] = useState(0); + const [recoveringCredential, setRecoveringCredential] = useState(false); + const modelCatalog = useModelCatalog().data; + const personalChoice = useModelChoice().data; const accepted = useQuery({ queryKey: keys.acceptedMaterials.of(projectId), retry: false, @@ -453,6 +463,8 @@ export function StageWorkspace({ setSendFailed(false); setSendFault(null); setAwaitingSince(null); + setDismissedCredentialAt(0); + setRecoveringCredential(false); setCaution(null); setFiles([]); setAttachFault(null); @@ -1016,6 +1028,49 @@ export function StageWorkspace({ runId, liveRun.running, ); + const credentialRecovery = archived + ? null + : resolveCredentialRecovery({ + turns: mirror, + failed: snapshot?.failed, + events, + liveCredentialFailed: Boolean(liveRun.credentialFailed), + liveEndedAt: liveRun.endedAt, + dismissedAt: dismissedCredentialAt, + catalog: modelCatalog, + choice: personalChoice, + }); + + const resumeAfterCredentialUpdate = async (profileId?: string) => { + const retryMessage = + credentialRecovery?.retryMessage ?? wording.credentials.continueMessage; + setRecoveringCredential(true); + setStartFault(null); + setModelNeeded(null); + setSendFailed(false); + setSendFault(null); + try { + await startProjectCoordinator(projectId, profileId); + setDismissedCredentialAt(Date.now()); + await queryClient.invalidateQueries({ queryKey: keys.modelCatalog }); + await queryClient.invalidateQueries({ queryKey: keys.modelChoice }); + await room.refetch(); + await deliver(retryMessage); + } catch (cause) { + const needed = await modelRefusal(cause); + if (needed !== undefined) return setModelNeeded(needed); + const fault = await humanFaultWithCode( + cause, + wording.message.coordinatorNotStarted, + ); + setSendFailed(true); + setSendFault(fault); + toast.error(fault); + } finally { + setRecoveringCredential(false); + } + }; + // A message sent here waits on the coordinator from the moment it leaves, before the stream // reports its run: until a reply follows it or a run ends after it. const awaitingReply = @@ -1024,11 +1079,14 @@ export function StageWorkspace({ (liveRun.endedAt === null || liveRun.endedAt < awaitingSince) && !archived && startFault === null && - modelNeeded === null; + modelNeeded === null && + credentialRecovery === null; // The mark stays for the whole turn: between model calls and during long tool work the live // reply shows nothing moving. const thinking = - !sendFailed && (sending || awaitingReply || (working && !held)); + !sendFailed && + credentialRecovery === null && + (sending || awaitingReply || (working && !held)); // Each concept's option is its own tab beside the others, never an ordinary document. const options = artifacts.filter((item) => item.kind === DESIGN_OPTION); const paneDeliverables = documentsForPane( @@ -1383,6 +1441,16 @@ export function StageWorkspace({ className="composer" data-working={working || sending ? "" : undefined} > + {credentialRecovery === null ? null : ( + void resumeAfterCredentialUpdate()} + onSwitchProfile={(profileId) => + void resumeAfterCredentialUpdate(profileId) + } + /> + )} {attachFault === null ? null : (

{attachFault} diff --git a/apps/web/src/pages/workspace/live-run.ts b/apps/web/src/pages/workspace/live-run.ts index ae0cc6b3..c67c0905 100644 --- a/apps/web/src/pages/workspace/live-run.ts +++ b/apps/web/src/pages/workspace/live-run.ts @@ -38,6 +38,8 @@ export type LiveRun = { readonly running: boolean; /** When this stream last saw a message run end, in ms. */ readonly endedAt: number | null; + /** The coordinator's turn stopped because the model provider refused its credential. */ + readonly credentialFailed?: boolean; readonly results: ReadonlyMap; }; @@ -69,6 +71,7 @@ const reloadAfter = new Set([ "inference.error", "tool.done", "connector.reply", + "turn.committed", "message.run.ended", "reactor.done", "reactor.error", @@ -107,16 +110,40 @@ export function foldLiveRun( segments: run.segments.filter((item) => item.text.length > 0), ended: false, running: true, + credentialFailed: false, }, reload, }; } - if (event.type === "message.run.ended" || event.type === "reactor.done") { + if ( + event.type === "message.run.ended" || + event.type === "turn.committed" || + event.type === "reactor.done" || + event.type === "reactor.error" + ) { return { run: { ...run, ended: true, running: false, endedAt: Date.now() }, reload, }; } + if ( + event.type === "inference.error" && + (event.data?.error?.category === "credential_failure" || + event.data?.error?.statusCode === 401 || + event.data?.error?.statusCode === 403) + ) { + return { + run: { + ...run, + current: emptySegment, + ended: true, + running: false, + endedAt: Date.now(), + credentialFailed: true, + }, + reload: true, + }; + } if (event.type === "tool.done") { const result = resultOf(event); if (result === undefined) return { run, reload }; diff --git a/apps/web/src/pages/workspace/transcript.test.tsx b/apps/web/src/pages/workspace/transcript.test.tsx index ab3610f2..4729bbb8 100644 --- a/apps/web/src/pages/workspace/transcript.test.tsx +++ b/apps/web/src/pages/workspace/transcript.test.tsx @@ -1,8 +1,12 @@ import { describe, expect, test } from "bun:test"; import { isValidElement, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { wording } from "../../config.ts"; +import { SPECIALIST_ENDED_LEAD, wording } from "../../config.ts"; import { DocumentDecision } from "./document-decision.tsx"; +import { + CredentialRecoveryBanner, + resolveCredentialRecovery, +} from "./credential-recovery.tsx"; import { awaitingCommit, emptyLiveRun, @@ -306,4 +310,173 @@ describe("conversation transcript", () => { expect(html).not.toContain(hidden); } }); + + test("scrubs raw credential failure text from turns and specialist ends and terminates live thinking", () => { + const rawError = + "This agent could not complete your request due to a credential error [HTTP 401]: invalid x-api-key"; + const html = renderToStaticMarkup( + , + ); + expect(html).toContain(wording.credentials.stopped); + expect(html).toContain(wording.credentials.cause); + expect(html).not.toContain("HTTP 401"); + expect(html).not.toContain("invalid x-api-key"); + + const started = foldLiveRun( + emptyLiveRun, + { type: "message.run.started" }, + 10, + ).run; + expect(started.running).toBe(true); + const failed = foldLiveRun( + started, + { + type: "inference.error", + data: { + error: { + category: "credential_failure", + statusCode: 401, + message: "invalid x-api-key", + }, + }, + }, + 20, + ).run; + expect(failed.running).toBe(false); + expect(failed.ended).toBe(true); + expect(failed.credentialFailed).toBe(true); + }); + + test("resolves credential recovery with re-authenticate and handoff actions and clears once a later turn succeeds", () => { + const binding = (provider: string) => ({ + provider, + model: "m", + effort: "medium" as const, + }); + const catalog = { + providers: [ + { + id: "anthropic", + plugin: "anthropic", + label: "Anthropic", + hint: "Claude", + baseURL: "https://api.anthropic.com", + kind: "api_key" as const, + models: [], + connections: [ + { + id: "prv_anthropic", + name: "Anthropic", + credentialSet: true, + status: "refused" as const, + note: "HTTP 401", + }, + ], + }, + { + id: "openai", + plugin: "openai", + label: "OpenAI", + hint: "GPT", + baseURL: "https://api.openai.com/v1", + kind: "api_key" as const, + models: [], + connections: [ + { + id: "prv_openai", + name: "OpenAI", + credentialSet: true, + status: "connected" as const, + }, + ], + }, + ], + roles: [], + tiers: [], + efforts: [], + customApis: [], + profiles: [ + { + id: "anthropic_default", + label: "Claude", + hint: "HTTP 401", + tiers: { + low: binding("anthropic"), + medium: binding("anthropic"), + high: binding("anthropic"), + max: binding("anthropic"), + }, + available: false, + }, + { + id: "openai_default", + label: "GPT", + hint: "OpenAI", + tiers: { + low: binding("openai"), + medium: binding("openai"), + high: binding("openai"), + max: binding("openai"), + }, + available: true, + }, + ], + }; + + const recovery = resolveCredentialRecovery({ + turns: [ + { role: "person", text: "Build the dashboard", at: 100 }, + { + role: "assistant", + text: "This agent could not complete your request due to a credential error [HTTP 401]: invalid x-api-key", + at: 200, + }, + ], + failed: [], + events: null, + liveCredentialFailed: false, + liveEndedAt: null, + dismissedAt: 0, + catalog, + choice: { profile: "anthropic_default" }, + }); + expect(recovery).not.toBeNull(); + expect(recovery?.retryMessage).toBe("Build the dashboard"); + expect(recovery?.provider?.id).toBe("anthropic"); + expect(recovery?.alternatives).toEqual([ + { id: "openai_default", label: "GPT" }, + ]); + + // Once a later assistant turn succeeds, recovery clears. + const cleared = resolveCredentialRecovery({ + turns: [ + { role: "person", text: "Build the dashboard", at: 100 }, + { + role: "assistant", + text: "This agent could not complete your request due to a credential error [HTTP 401]: invalid x-api-key", + at: 200, + }, + { role: "assistant", text: "Here is the dashboard.", at: 300 }, + ], + failed: [], + events: null, + liveCredentialFailed: false, + liveEndedAt: null, + dismissedAt: 0, + catalog, + choice: { profile: "anthropic_default" }, + }); + expect(cleared).toBeNull(); + expect(CredentialRecoveryBanner).toBeDefined(); + }); }); diff --git a/apps/web/src/pages/workspace/transcript.tsx b/apps/web/src/pages/workspace/transcript.tsx index d724aab1..099cd5b1 100644 --- a/apps/web/src/pages/workspace/transcript.tsx +++ b/apps/web/src/pages/workspace/transcript.tsx @@ -55,7 +55,11 @@ import { CONV_SCROLL_CLASS } from "./pane-classes.ts"; import { proseSegments, type NamedDocument } from "./collaboration.ts"; import { DocumentDecision, PackageDecision } from "./document-decision.tsx"; import { TypedYesCountdown, type TypedYes } from "./typed-yes.tsx"; -import { turnLabel, visibleTurnParts } from "./visible-chat.ts"; +import { + isCredentialFailureText, + turnLabel, + visibleTurnParts, +} from "./visible-chat.ts"; import { distinctUpdates, FoldedUpdates, @@ -564,13 +568,14 @@ function SpecialistEnded({ text }: { text: string }) { const [line = "", rest = ""] = text.split(/\n\n([\s\S]*)/); const recorded = RECORDED_REASON.exec(rest); const code = recorded?.[1]; - const cause = - code === undefined ? undefined : wording.specialistEnded.cause[code]; + let cause: string | undefined; + if (isCredentialFailureText(rest)) cause = wording.credentials.cause; + else if (code !== undefined) cause = wording.specialistEnded.cause[code]; return ( {cause === undefined ? line : `${line.replace(/\.$/, "")} (${cause}).`}{" "} {wording.specialistEnded.restart} - {recorded === null ? null : ( + {recorded === null || isCredentialFailureText(rest) ? null : (

{wording.specialistEnded.details} {code === undefined ? recorded[2] : `${code}: ${recorded[2] ?? ""}`} @@ -841,7 +846,12 @@ export function RoomThread({ ); const mapTurns = sections.flatMap( (section, position): ConversationMapTurn[] => { - if (section.kind !== "person" || section.turn.text.trim() === "") + if ( + section.kind !== "person" || + section.turn.text.trim() === "" || + section.turn.text.startsWith(IMPORTED_PROJECT_LEAD) || + section.turn.text.startsWith(SPECIALIST_ENDED_LEAD) + ) return []; const next = sections[position + 1]; const reply = diff --git a/apps/web/src/pages/workspace/visible-chat.ts b/apps/web/src/pages/workspace/visible-chat.ts index 8f0df425..44ad0ac0 100644 --- a/apps/web/src/pages/workspace/visible-chat.ts +++ b/apps/web/src/pages/workspace/visible-chat.ts @@ -21,6 +21,12 @@ const LABELED = /\b(?:eventCount|toolCallId|partialArguments|deliverableId|artifactId|projectId|correlationId|runId)\b(?:\s*[:=]\s*(?:"[^"]+"|'[^']+'|\S+))?/; // "blocker" is also a plain word a person reads; only its labeled form is bookkeeping. const BLOCKER = /\bblockers?\s*[:=]\s*(?:"[^"]+"|'[^']+'|\S+)/; +const CREDENTIAL_FAILURE_RE = + /(?:due to a credential error\b|\bcredential_failure\b|\[HTTP\s*(?:401|403)\]|\binvalid[\s_-]+x-api-key\b|\bauthentication_error\b)/i; + +export function isCredentialFailureText(text: string): boolean { + return CREDENTIAL_FAILURE_RE.test(text); +} const INTERNAL = [ SNAKE, @@ -250,6 +256,7 @@ function scrubLine(line: string): string | null { * own words stay as they wrote them. */ export function visibleChatText(text: string): string { + if (isCredentialFailureText(text)) return wording.credentials.stopped; const source = stripInternalCode( stripInternalAsides(stripJson(stripInternalFences(text))), ); diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 1a2a2094..e6390633 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1481,6 +1481,9 @@ select.quiet-control { } .banner-action { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); margin-top: var(--space-1); } diff --git a/packages/client/src/client/credentials.ts b/packages/client/src/client/credentials.ts index 57316c42..da5e55ab 100644 --- a/packages/client/src/client/credentials.ts +++ b/packages/client/src/client/credentials.ts @@ -274,6 +274,86 @@ export function buildCredentialsMethods( signal, ); + const matching = (await modelProviders(tenantId, signal)).filter( + (row) => row.plugin === input.plugin && row.baseURL === baseURL, + ); + const reusable = + matching.find((row) => !row.disabled && row.credentialId !== null) ?? + matching.find((row) => row.credentialId !== null); + if (reusable?.credentialId) { + try { + const updated = (await request( + `${root}/credentials/${encodeURIComponent(reusable.credentialId)}`, + signal, + { secret: input.secret, status: "active" }, + "PATCH", + )) as { id?: unknown; name?: unknown }; + if (reusable.disabled) { + await request( + `${root}/catalog/providers/${encodeURIComponent(reusable.id)}`, + signal, + { disabled: false }, + "PATCH", + ); + } + for (const other of matching) { + if (other.id !== reusable.id && !other.disabled) { + try { + await request( + `${root}/catalog/providers/${encodeURIComponent(other.id)}`, + signal, + { disabled: true }, + "PATCH", + ); + } catch (cause) { + void cause; + } + } + } + return { + provider: { + id: nativeProvider.id, + name: `byok-${input.plugin}`, + plugin: input.plugin, + baseURL, + }, + credential: { + id: + typeof updated?.id === "string" + ? updated.id + : reusable.credentialId, + name: + typeof updated?.name === "string" + ? updated.name + : reusable.name, + }, + modelProvider: { + id: reusable.id, + name: reusable.name, + credentialId: reusable.credentialId, + }, + }; + } catch (cause) { + // Inherited or deleted credentials cannot be updated in place; mint a fresh tenant row. + void cause; + } + } + + for (const row of matching) { + if (!row.disabled) { + try { + await request( + `${root}/catalog/providers/${encodeURIComponent(row.id)}`, + signal, + { disabled: true }, + "PATCH", + ); + } catch (cause) { + void cause; + } + } + } + // Org-owned (no principalId) so the catalog provider accepts a tenant-owned reference. const credential = (await request(`${root}/credentials`, signal, { providerId: nativeProvider.id, diff --git a/packages/client/src/client/projects.ts b/packages/client/src/client/projects.ts index bbd822cd..4993bdcd 100644 --- a/packages/client/src/client/projects.ts +++ b/packages/client/src/client/projects.ts @@ -71,11 +71,11 @@ export function buildProjectsMethods( {}, ); }, - startProjectCoordinator(tenantId, projectId, signal) { + startProjectCoordinator(tenantId, projectId, signal, options) { return request( `${projectPath(tenantId, projectId)}/coordinator`, signal, - {}, + { profile: options?.profile }, ); }, projectNextStep(tenantId, projectId, signal) { diff --git a/packages/client/src/client/shared.ts b/packages/client/src/client/shared.ts index 1ca2008c..eb95be7c 100644 --- a/packages/client/src/client/shared.ts +++ b/packages/client/src/client/shared.ts @@ -343,6 +343,7 @@ export type ProductApiClient = Readonly<{ tenantId: string, projectId: string, signal?: AbortSignal, + options?: { profile?: string }, ): Promise<{ started: boolean }>; projectNextStep( tenantId: string,