diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 52c4e351..4d316e74 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ We'd like to try something a little different with this repo. -Given that coding agents write most underlying code now, we'd prefer PRs in the form of _human-written_ +Given that coding agents write most underlying code now, we'd prefer `feature` PRs in the form of _human-written_ text. This can be quite informal — just run your idea by us in the same way you would a coworker or friend, say, over Slack. If we're aligned on the change, we're happy to burn our tokens on the underlying implementation. @@ -11,4 +11,6 @@ Please do not have AI artificially expand what you'd like to do into a formal pr Submit changes as a PR adding a `.txt` or `.md` file to the [`adrs/`](./adrs/) folder. +For bugs, just open an issue. We appreciate this a lot, and will credit you as co-author on the commit if we merge a fix. + PS: Report any security vulnerabilities privately — see [`SECURITY.md`](./SECURITY.md), not a public issue. diff --git a/SECURITY.md b/SECURITY.md index c0a5bd4d..923cf4ae 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -83,6 +83,29 @@ plaintext credentials while a process is using them. An approval means a human accepted the displayed action under the information available at that time, not that the resulting behavior is safe. +### Deliberately portal-only actions + +Three actions are intentionally excluded from the agent self-API, even though the +web portal offers them. They look like capability-parity gaps in an audit; they are +walls, not gaps, and should not be "fixed" without revisiting the reasoning here. + +- **Admin grant changes.** Granting or revoking org-admin rights happens only in the + portal, on an authenticated admin's own turn. If the agent could change grants, a + prompt-injected or compromised agent process could escalate its own operator's + privileges — or demote everyone else's. +- **Impersonation.** The agent always acts as the principal resolved for the turn. + There is no self-API route to act as a different principal, because every + authorization decision downstream keys off that identity; a switchable identity + would turn one confused turn into another person's authority. +- **Command-approval decisions.** Approving a gated command is a human judgment made + on the approver's own turn. An agent-reachable approval route would collapse the + human-in-the-loop gate into a single model decision, which is exactly what the + gate exists to prevent. + +The common shape: each is a decision that authorizes _future_ agent behavior, so the +decision itself must come from outside the agent. Parity work should route around +these, not through them. + ### Known limitations - **Command policy is bypassable.** It classifies shell text and catches configured or diff --git a/package.json b/package.json index 17bc5dfc..1cdc313d 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "start": "node --env-file-if-exists=.env src/index.ts", "dev": "SHUTDOWN_DRAIN_MS=2000 node --env-file-if-exists=.env --watch src/index.ts", "dev-instance": "bash scripts/dev-instance.sh up", + "dev-instance:no-slack": "bash scripts/dev-instance.sh up --no-slack", "dev-instance:status": "bash scripts/dev-instance.sh status", "dev-instance:down": "bash scripts/dev-instance.sh down", "worker": "node --env-file-if-exists=.env src/runs/worker-main.ts", diff --git a/plugins/admin/public/index.html b/plugins/admin/public/index.html index f019ed3b..e7c0367b 100644 --- a/plugins/admin/public/index.html +++ b/plugins/admin/public/index.html @@ -3905,6 +3905,72 @@

Base model

> +
+
+

Custom providers

+

+ Point QM at any OpenAI- or Anthropic-compatible endpoint — a vendor like DeepSeek, or a gateway like + LiteLLM fronting many models. Keys are validated, stored write-only, and the models join the picker. +

+
+
+ + + + + + + + + + + + +
ProviderProtocolEndpointModelsKey
+ +
+
+ + + + + + + +
+
+ +
+
@@ -7044,6 +7110,7 @@

Confirm governance change

$("onboarding-model-save").disabled = false; $("onboarding-model-key").value = ""; await loadOnboarding(); + await loadCustomProviders(); setStatus( "st-onboarding-model", selected.ok ? "Key and base model saved." : "Key saved, but the base model could not be changed.", @@ -7065,8 +7132,109 @@

Confirm governance change

return; } await loadOnboarding(); + await loadCustomProviders(); setStatus("st-onboarding-model", "Provider disabled.", "ok"); }; + let customProvidersLoaded = []; + function parseCustomModels(text) { + return text + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [id, name, contextWindow, maxTokens] = line.split("|").map((part) => part.trim()); + const model = { id }; + if (name) model.name = name; + if (contextWindow) model.contextWindow = Number(contextWindow); + if (maxTokens) model.maxTokens = Number(maxTokens); + return model; + }); + } + async function loadCustomProviders() { + const res = await api("GET", "/api/custom-providers"); + if (!res.ok) return; + customProvidersLoaded = (res.data?.providers || []).filter((provider) => !provider.disabled); + const rows = $("custom-provider-rows"); + rows.textContent = ""; + $("custom-provider-empty").hidden = customProvidersLoaded.length > 0; + customProvidersLoaded.forEach((provider) => { + const tr = document.createElement("tr"); + const cells = [ + provider.name + " (" + provider.id + ")", + provider.protocol === "anthropic" ? "Anthropic" : "OpenAI", + provider.baseUrl, + provider.models.map((model) => model.id).join(", "), + provider.hasKey ? "set (write-only)" : "none", + ]; + cells.forEach((textContent) => { + const td = document.createElement("td"); + td.textContent = textContent; + tr.appendChild(td); + }); + const actions = document.createElement("td"); + const edit = document.createElement("button"); + edit.textContent = "Edit"; + edit.onclick = () => { + $("custom-provider-id").value = provider.id; + $("custom-provider-name").value = provider.name; + $("custom-provider-protocol").value = provider.protocol; + $("custom-provider-url").value = provider.baseUrl; + $("custom-provider-key").value = ""; + $("custom-provider-models").value = provider.models + .map((model) => + [model.id, model.name, model.contextWindow, model.maxTokens].filter((part) => part != null).join(" | "), + ) + .join("\n"); + }; + const remove = document.createElement("button"); + remove.className = "danger"; + remove.textContent = "Remove"; + remove.onclick = async () => { + if (!confirm("Remove " + provider.name + "? Its models leave every model picker.")) return; + const removed = await api("DELETE", "/api/custom-providers/" + encodeURIComponent(provider.id)); + if (!removed.ok) { + setStatus("st-custom-provider", removed.data?.message || "Could not remove this provider.", "err"); + return; + } + await loadCustomProviders(); + setStatus("st-custom-provider", "Provider removed.", "ok"); + }; + actions.appendChild(edit); + actions.appendChild(remove); + tr.appendChild(actions); + rows.appendChild(tr); + }); + } + $("custom-provider-save").onclick = async () => { + const id = $("custom-provider-id").value.trim(); + const name = $("custom-provider-name").value.trim(); + const baseUrl = $("custom-provider-url").value.trim(); + const models = parseCustomModels($("custom-provider-models").value); + if (!id || !name || !baseUrl || models.length === 0) { + setStatus("st-custom-provider", "Provider id, name, base URL, and at least one model are required.", "err"); + return; + } + const apiKey = $("custom-provider-key").value.trim(); + const body = { + name, + protocol: $("custom-provider-protocol").value, + baseUrl, + models, + ...(apiKey ? { apiKey } : {}), + ...($("custom-provider-validate").checked ? {} : { validate: false }), + }; + $("custom-provider-save").disabled = true; + setStatus("st-custom-provider", "Saving…", "saving", true); + const saved = await api("PUT", "/api/custom-providers/" + encodeURIComponent(id), body); + $("custom-provider-save").disabled = false; + if (!saved.ok) { + setStatus("st-custom-provider", saved.data?.message || "Could not save this provider.", "err", true); + return; + } + $("custom-provider-key").value = ""; + await loadCustomProviders(); + setStatus("st-custom-provider", "Provider saved. Its models are now in the picker.", "ok"); + }; function openOnboardingTarget(target) { setView("connectors"); setTimeout( diff --git a/plugins/admin/src/index.ts b/plugins/admin/src/index.ts index f4902bd3..25106783 100644 --- a/plugins/admin/src/index.ts +++ b/plugins/admin/src/index.ts @@ -267,6 +267,7 @@ const WRITES = new Map([ ["users", ["PUT", "POST"]], ["slack-installation", ["PUT", "DELETE"]], ["model-providers", ["PUT", "DELETE"]], + ["custom-providers", ["PUT", "DELETE"]], ]); const READS = [ @@ -291,6 +292,7 @@ const READS = [ "ack-emoji-picks", "slack-installation", "model-providers", + "custom-providers", ]; const server = createServer((req, res) => { diff --git a/plugins/web-ui/package-lock.json b/plugins/web-ui/package-lock.json index 9d34b1ac..f433146f 100644 --- a/plugins/web-ui/package-lock.json +++ b/plugins/web-ui/package-lock.json @@ -664,7 +664,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -713,7 +712,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -1335,7 +1333,6 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/@mariozechner/mini-lit/-/mini-lit-0.2.1.tgz", "integrity": "sha512-u300euLgCsDDlb8o2Wbz+55eSJga5X2vB58s9XBuFIr2Bi3iI+GMR7t/NYo/O6Vr6obXShXgYjR3SRUJVgo+kQ==", - "peer": true, "dependencies": { "@preact/signals-core": "^1.12.1", "class-variance-authority": "^0.7.1", @@ -2955,7 +2952,6 @@ "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz", "integrity": "sha512-fycuvZg/hkpozL00lm1pEJH5nN/lr9ZXd6mJI2HSN4+Bzc+LDNdEApJ6HFbPkdFNHLvOplIIuJvxkS4XUxqirw==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", @@ -3193,7 +3189,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3454,7 +3449,6 @@ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/dcastil" @@ -3835,7 +3829,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/plugins/web-ui/src/chat.ts b/plugins/web-ui/src/chat.ts index 9a4d275a..de3ec466 100644 --- a/plugins/web-ui/src/chat.ts +++ b/plugins/web-ui/src/chat.ts @@ -41,8 +41,13 @@ import { entriesToMessages, fetchEntry, fetchTranscript, + currentEarlierCount, + forkOriginDetails, forkCutSeq, forkSession, + inheritedRefreshEntries, + inheritedTranscript, + loadInheritedTranscript, makeCoreStreamFn, makeOpenerStreamFn, makeRunResumeStreamFn, @@ -82,16 +87,19 @@ import { browserRenderableImage, formatBytes, icon, relTime } from "./ui"; import { adminSessionLogUrl, appState, can, renderSidebarTop, syncUrlFromState } from "./shell"; import { addPendingSession, + dropPendingSession, groupDmTitle, refreshSessions, renderList, sessionsState, sessionSlackUrl, surfaceOf, + openSession, } from "./sessions"; -import { backgroundLabel, clearWorking, conversationBackground, markWorking } from "./session-list"; +import { backgroundLabel, clearWorking, conversationBackground, isAbandonedNewChat, markWorking } from "./session-list"; import { liveTurnThreadRef } from "./working-dot"; import { newChatDraftKey, saveDraft, storedDraft } from "./drafts"; +import { createForkOriginController, forkOriginView } from "./fork-origin"; installMarkdownSanitizer(); @@ -122,8 +130,13 @@ export function markConnectorConnected(provider: string): void { for (const hook of redrawHooks) hook(); } -export function createChatSurface(ctx: ConvCtx): ChatSurface { +export function createChatSurface( + ctx: ConvCtx, + dependencies: { fetchTranscript?: typeof fetchTranscript; openSession?: typeof openSession } = {}, +): ChatSurface { const runSlot = createRunSlot(); + const transcriptFetcher = dependencies.fetchTranscript ?? fetchTranscript; + const sessionOpener = dependencies.openSession ?? openSession; const chatState = { agent: null as Agent | null, @@ -144,7 +157,37 @@ export function createChatSurface(ctx: ConvCtx): ChatSurface { transcriptAnchorSeq: null as number | null, earlierCount: 0, loadingEarlier: false, + forkSession: null as CoreSession | null, + inheritedMessages: [] as ReturnType, + inheritedExpanded: false, + inheritedLoaded: false, }; + const forkOriginController = createForkOriginController({ + state: chatState, + load: async () => { + const session = chatState.forkSession; + if (!session) return []; + const entries = await loadInheritedTranscript(session, [], transcriptFetcher); + return entriesToMessages(entries, transcriptModel()); + }, + navigate: async () => { + const sourceId = chatState.forkSession?.forkedFrom?.sessionId; + if (!sourceId) return; + const listed = sessionsState.list.find((session) => session.id === sourceId); + const page = await transcriptFetcher(sourceId, { tailTurns: TAIL_TURNS }); + const source = listed ?? page.session; + if (!source) throw new Error("missing source session"); + await sessionOpener(source, Promise.resolve(page)); + }, + current: () => Boolean(chatState.forkSession && chatState.sessionId === chatState.forkSession.id), + redraw: () => { + if (chatState.agent) drawActiveChat(); + else readonlyRedraw?.(); + }, + setError: (error) => { + ctx.composer.state.error = error; + }, + }); let workTicker: ReturnType | null = null; let revealedTailLen = 0; @@ -163,6 +206,7 @@ export function createChatSurface(ctx: ConvCtx): ChatSurface { let readOnlyView: { id: string; threadRef: string; session: CoreSession; anchorSeq: number | null } | null = null; function teardownActiveChat(): void { + forkOriginController.invalidateRefresh(); readOnlyView = null; preserveOutgoingWorkingDot(null); detachActiveAgent(); @@ -183,6 +227,7 @@ export function createChatSurface(ctx: ConvCtx): ChatSurface { } function resetChatState(): void { + dropAbandonedNewChat(null); teardownActiveChat(); proactiveOpenerStarted = false; chatState.rememberedThreadRef = null; @@ -200,12 +245,29 @@ export function createChatSurface(ctx: ConvCtx): ChatSurface { const carried = storedDraft(newChatDraftKey(user)); if (carried) saveDraft(threadRef, carried); ctx.composer.resetComposer(); + forkOriginController.reset(); mountContinuable(threadRef, null, context?.scopeId ?? null, [], context?.name ?? null); renderList(); ctx.composer.focusComposerEnd(); return threadRef; } + function dropAbandonedNewChat(nextThreadRef: string | null): void { + const ref = chatState.threadRef; + if ( + isAbandonedNewChat({ + threadRef: ref, + nextThreadRef, + sessionId: chatState.sessionId, + pendingSend: chatState.pendingSend, + hasHumanMessage: (chatState.agent?.state.messages ?? []).some((m) => !(m as { opener?: boolean }).opener), + draft: ctx.composer.state.draft || storedDraft(ref ?? ""), + attachments: ctx.composer.state.attachments.length, + }) + ) + dropPendingSession(ref!); + } + function preserveOutgoingWorkingDot(nextThreadRef: string | null): void { const live = liveTurnThreadRef({ mountedThreadRef: chatState.threadRef, @@ -228,17 +290,25 @@ export function createChatSurface(ctx: ConvCtx): ChatSurface { scopeId: string | null, messages: ReturnType, contextName: string | null = null, + session?: CoreSession, + inheritedMessages: ReturnType = [], ): void { const container = ctx.claimContainer(); if (!container) return; readOnlyView = null; preserveOutgoingWorkingDot(threadRef); + dropAbandonedNewChat(threadRef); detachActiveAgent(); ctx.composer.resetComposer(); + forkOriginController.reset(); chatState.threadRef = threadRef; chatState.sessionId = sessionId; chatState.scopeId = scopeId; chatState.contextName = contextName; + chatState.forkSession = session ?? null; + chatState.inheritedMessages = inheritedMessages; + chatState.inheritedExpanded = false; + chatState.inheritedLoaded = !session?.forkedFrom; chatState.rememberedThreadRef = threadRef; chatState.rememberedSessionId = sessionId; chatState.rememberedScopeId = scopeId; @@ -377,18 +447,39 @@ export function createChatSurface(ctx: ConvCtx): ChatSurface { }; } + function inheritedHeader(): TemplateResult | typeof nothing { + const origin = chatState.forkSession + ? forkOriginDetails(chatState.forkSession, chatState.inheritedLoaded ? chatState.inheritedMessages.length : 0) + : null; + return forkOriginView( + origin + ? { + title: origin.title, + messageCount: origin.messageCount, + expanded: chatState.inheritedExpanded, + icon: icon(GitFork, 14), + navigate: () => void forkOriginController.navigate(), + toggle: () => void forkOriginController.toggle().catch(() => {}), + } + : null, + ); + } + function onDelivery(threadRef: string): void { const ro = readOnlyView; if (ro && threadRef === ro.threadRef) { void fetchTranscript(ro.id, ro.anchorSeq !== null ? { sinceSeq: ro.anchorSeq } : { tailTurns: TAIL_TURNS }) .then((page) => { if (readOnlyView?.id !== ro.id) return; - const earlier = page.earlierEntries ?? 0; + const split = inheritedTranscript(ro.session, page.entries ?? []); + const rawEarlier = page.earlierEntries ?? 0; + const earlier = currentEarlierCount(ro.session, rawEarlier); mountReadOnly( readOnlyView.session, - entriesToMessages(page.entries ?? [], transcriptModel()), + entriesToMessages(split.current, transcriptModel()), earlier, - earlier > 0 ? (page.entries?.[0]?.seq ?? null) : null, + rawEarlier > 0 ? (page.entries?.[0]?.seq ?? null) : null, + entriesToMessages(split.inherited, transcriptModel()), ); }) .catch(() => {}); @@ -480,13 +571,30 @@ export function createChatSurface(ctx: ConvCtx): ChatSurface { async function refreshTranscriptFromEntries(agent: Agent): Promise { const sessionId = chatState.sessionId; if (!sessionId || agent !== chatState.agent || agent.state.isStreaming) return drawActiveChat(agent); + const generation = forkOriginController.beginRefresh(); const last = agent.state.messages[agent.state.messages.length - 1] as { stopReason?: string } | undefined; if (last?.stopReason === "error" || last?.stopReason === "aborted") return drawActiveChat(agent); try { const anchor = chatState.transcriptAnchorSeq; - const page = await fetchTranscript(sessionId, anchor !== null ? { sinceSeq: anchor } : undefined); - if (agent !== chatState.agent || agent.state.isStreaming) return; - const messages = entriesToMessages(page.entries ?? [], transcriptModel()); + const page = await transcriptFetcher(sessionId, anchor !== null ? { sinceSeq: anchor } : undefined); + if ( + !forkOriginController.isCurrentRefresh(generation) || + sessionId !== chatState.sessionId || + agent !== chatState.agent || + agent.state.isStreaming + ) + return; + const split = inheritedTranscript(chatState.forkSession ?? {}, page.entries ?? []); + const messages = entriesToMessages(split.current, transcriptModel()); + const refreshedInherited = inheritedRefreshEntries( + chatState.forkSession ?? {}, + page.entries ?? [], + chatState.inheritedLoaded, + ); + forkOriginController.applyRefresh( + generation, + refreshedInherited ? entriesToMessages(refreshedInherited, transcriptModel()) : null, + ); try { const r = await api<{ approvals: PendingApproval[] }>( `/api/sessions/${encodeURIComponent(sessionId)}/approvals`, @@ -495,10 +603,17 @@ export function createChatSurface(ctx: ConvCtx): ChatSurface { } catch { void 0; } - if (agent !== chatState.agent || agent.state.isStreaming) return; + if ( + !forkOriginController.isCurrentRefresh(generation) || + sessionId !== chatState.sessionId || + agent !== chatState.agent || + agent.state.isStreaming + ) + return; agent.state.messages = messages; - chatState.earlierCount = page.earlierEntries ?? 0; - chatState.transcriptAnchorSeq = chatState.earlierCount > 0 ? (page.entries?.[0]?.seq ?? null) : null; + const rawEarlier = page.earlierEntries ?? 0; + chatState.earlierCount = currentEarlierCount(chatState.forkSession ?? {}, rawEarlier); + chatState.transcriptAnchorSeq = rawEarlier > 0 ? (page.entries?.[0]?.seq ?? null) : null; } catch { void 0; } @@ -627,18 +742,28 @@ export function createChatSurface(ctx: ConvCtx): ChatSurface { messages: ReturnType, earlierCount = 0, anchorSeq: number | null = null, + inheritedMessages: ReturnType = [], ): void { const container = ctx.claimContainer(); if (!container) return; preserveOutgoingWorkingDot(s.threadRef); + dropAbandonedNewChat(s.threadRef); detachActiveAgent(); chatState.agent = null; clearLiveWork(); chatState.host = null; ctx.composer.resetComposer(); + const sameSession = chatState.sessionId === s.id && chatState.threadRef === null; + if (!sameSession) forkOriginController.reset(); chatState.threadRef = null; chatState.sessionId = s.id; chatState.scopeId = s.scopeId; + chatState.forkSession = s; + if (!(sameSession && chatState.inheritedLoaded)) { + chatState.inheritedMessages = inheritedMessages; + chatState.inheritedLoaded = !s.forkedFrom; + } + if (!sameSession) chatState.inheritedExpanded = false; syncLocation(); resetBackgroundPanel(); @@ -670,6 +795,7 @@ export function createChatSurface(ctx: ConvCtx): ChatSurface { ${backgroundActivityStrip()}
+ ${inheritedHeader()} ${ earlierCount > 0 ? html`
@@ -688,12 +814,18 @@ export function createChatSurface(ctx: ConvCtx): ChatSurface { const scroller = container?.querySelector(".chat-scroll"); const priorHeight = scroller?.scrollHeight ?? 0; const priorTop = scroller?.scrollTop ?? 0; - const remaining = page.earlierEntries ?? 0; + const rawRemaining = page.earlierEntries ?? 0; + const remaining = currentEarlierCount(s, rawRemaining); + const split = inheritedTranscript(s, page.entries ?? []); mountReadOnly( s, - [...entriesToMessages(page.entries ?? [], transcriptModel()), ...messages], + [...entriesToMessages(split.current, transcriptModel()), ...messages], remaining, - remaining > 0 ? (page.entries?.[0]?.seq ?? null) : null, + rawRemaining > 0 ? (page.entries?.[0]?.seq ?? null) : null, + [ + ...entriesToMessages(split.inherited, transcriptModel()), + ...chatState.inheritedMessages, + ], ); requestAnimationFrame(() => { const scrollerNow = container?.querySelector(".chat-scroll"); @@ -711,7 +843,14 @@ export function createChatSurface(ctx: ConvCtx): ChatSurface {
` : nothing } - ${messages.length ? messages.map((m, i) => chatMessage(m, i)) : html`
No readable messages in this conversation.
`} + ${ + (chatState.inheritedExpanded ? [...chatState.inheritedMessages, ...messages] : messages).length + ? (chatState.inheritedExpanded ? [...chatState.inheritedMessages, ...messages] : messages).map( + (m, i) => chatMessage(m, i), + ) + : html`
No readable messages in this conversation.
` + } + ${ctx.composer.state.error ? html`
${ctx.composer.state.error}
` : nothing}
@@ -742,8 +881,8 @@ export function createChatSurface(ctx: ConvCtx): ChatSurface { `; } - function setTranscriptWindow(anchorSeq: number | null, earlierCount: number): void { - chatState.transcriptAnchorSeq = earlierCount > 0 ? anchorSeq : null; + function setTranscriptWindow(anchorSeq: number | null, earlierCount: number, hasEarlier = earlierCount > 0): void { + chatState.transcriptAnchorSeq = hasEarlier ? anchorSeq : null; chatState.earlierCount = earlierCount; if (chatState.agent) drawActiveChat(chatState.agent); } @@ -770,14 +909,20 @@ export function createChatSurface(ctx: ConvCtx): ChatSurface { try { const page = await fetchTranscript(sessionId, { beforeSeq: anchor, tailTurns: TAIL_TURNS }); if (agent !== chatState.agent || agent.state.isStreaming) return; - const earlierMessages = entriesToMessages(page.entries ?? [], transcriptModel()); + const split = inheritedTranscript(chatState.forkSession ?? {}, page.entries ?? []); + const earlierMessages = entriesToMessages(split.current, transcriptModel()); + if (!chatState.inheritedLoaded) + chatState.inheritedMessages = [ + ...entriesToMessages(split.inherited, transcriptModel()), + ...chatState.inheritedMessages, + ]; const scroller = chatState.host?.querySelector(".chat-scroll"); const priorHeight = scroller?.scrollHeight ?? 0; const priorTop = scroller?.scrollTop ?? 0; agent.state.messages = [...earlierMessages, ...agent.state.messages]; - const remaining = page.earlierEntries ?? 0; - chatState.transcriptAnchorSeq = remaining > 0 ? (page.entries?.[0]?.seq ?? null) : null; - chatState.earlierCount = remaining; + const rawRemaining = page.earlierEntries ?? 0; + chatState.transcriptAnchorSeq = rawRemaining > 0 ? (page.entries?.[0]?.seq ?? null) : null; + chatState.earlierCount = currentEarlierCount(chatState.forkSession ?? {}, rawRemaining); chatState.loadingEarlier = false; drawActiveChat(agent); requestAnimationFrame(() => { @@ -847,12 +992,16 @@ export function createChatSurface(ctx: ConvCtx): ChatSurface { function drawActiveChat(agent = chatState.agent, opts: { forceScroll?: boolean } = {}): void { if (!agent || agent !== chatState.agent || !chatState.host || appState.currentView !== "chats") return; - const messages = visibleMessages(agent); + const currentMessages = visibleMessages(agent); + const messages = chatState.inheritedExpanded + ? [...chatState.inheritedMessages, ...currentMessages] + : currentMessages; const isNewUser = sessionsState.list.filter((s) => s.id).length === 0; let messageContent: Array | TemplateResult | typeof nothing = nothing; + const inheritedOffset = chatState.inheritedExpanded ? chatState.inheritedMessages.length : 0; if (messages.length) { messageContent = messages.map((m, i) => - settledChatMessage(m, i, agent.state.isStreaming && m === agent.state.streamingMessage), + settledChatMessage(m, i - inheritedOffset, agent.state.isStreaming && m === agent.state.streamingMessage), ); } else if (isNewUser) { messageContent = welcomeGreeting(); @@ -880,8 +1029,9 @@ export function createChatSurface(ctx: ConvCtx): ChatSurface { glanceTier ? paneGlance(agent, messages, glanceTier) : html`
-
- ${chatState.earlierCount > 0 ? earlierNotice(agent) : nothing} ${messageContent} +
+ ${inheritedHeader()} ${chatState.earlierCount > 0 ? earlierNotice(agent) : nothing} + ${messageContent} ${showStateError(messages, agent.state.errorMessage) ? html`
${agent.state.errorMessage}
` : nothing}
` @@ -1078,7 +1228,7 @@ export function createChatSurface(ctx: ConvCtx): ChatSurface { const text = messageText(message).trim(); const ts = (message as { timestamp?: number }).timestamp; if (!text && ts === undefined) return nothing; - const forkable = Boolean(chatState.threadRef && chatState.sessionId && chatState.agent); + const forkable = Boolean(index >= 0 && chatState.threadRef && chatState.sessionId && chatState.agent); return html`
${ts !== undefined ? html`${formatClock(ts)}` : nothing} @@ -1132,13 +1282,16 @@ export function createChatSurface(ctx: ConvCtx): ChatSurface { if (anchor !== null) userOrdinal += userMessagesBefore(entries ?? [], anchor); const upToSeq = forkCutSeq(entries ?? [], userOrdinal, isUser); const forked = await forkSession(sessionId, upToSeq); + const split = inheritedTranscript(forked.session, forked.entries ?? []); ctx.composer.carryModelPick(sourceThreadRef, forked.session.threadRef); mountContinuable( forked.session.threadRef, forked.session.id, forked.session.scopeId, - entriesToMessages(forked.entries ?? [], transcriptModel()), + entriesToMessages(split.current, transcriptModel()), forked.session.channelName ?? null, + forked.session, + entriesToMessages(split.inherited, transcriptModel()), ); await refreshSessions({ silent: true }); renderList(); diff --git a/plugins/web-ui/src/composer.ts b/plugins/web-ui/src/composer.ts index 52612bd3..1e0e1a63 100644 --- a/plugins/web-ui/src/composer.ts +++ b/plugins/web-ui/src/composer.ts @@ -292,6 +292,10 @@ export function createComposerSurface(ctx: ConvCtx): ComposerSurface { config.effective, config.modelCatalog, ); + composerState.effortLevel = + (config.effective.effortLevel as EffortLevel | undefined) ?? defaultEffortForModel(currentModelOption().model); + composerState.fastMode = + config.effective.fastMode === true && modelSupportsFastMode(scopeKey(), config.effective.modelId); if (agent && (!ctx.chat.state.threadRef || !threadModelPicks.has(ctx.chat.state.threadRef))) agent.state.model = currentModelOption().model; ctx.chat.drawActiveChat(agent); @@ -299,7 +303,14 @@ export function createComposerSurface(ctx: ConvCtx): ComposerSurface { } async function changeScopeRuntime( - change: { harnessId?: string; modelId?: string; inherit?: boolean; keep?: boolean }, + change: { + harnessId?: string; + modelId?: string; + effortLevel?: string; + fastMode?: boolean; + inherit?: boolean; + keep?: boolean; + }, agent: Agent, ): Promise { const request = ++runtimeRequest; @@ -308,19 +319,7 @@ export function createComposerSurface(ctx: ConvCtx): ComposerSurface { const config = await updateRuntimeConfig(scopeId, change); if (request !== runtimeRequest || scopeId !== ctx.chat.state.scopeId) return; seededRuntime = null; - activeRuntimeConfig = config; - setFastModeModelIds(scopeKey(), config.fastModeModelIds); - orgFastModeDefault = config.interactiveFastMode === true; - applyRuntimeOptions( - scopeKey(), - config.approvedHarnesses, - config.modelsByHarness, - config.effective, - config.modelCatalog, - ); - if (!ctx.chat.state.threadRef || !threadModelPicks.has(ctx.chat.state.threadRef)) - agent.state.model = currentModelOption().model; - composerState.error = ""; + applySelectedRuntime(config, agent); } catch (e) { if (request !== runtimeRequest || scopeId !== ctx.chat.state.scopeId) return; composerState.error = errMessage(e, "Could not update the scope default."); @@ -339,7 +338,15 @@ export function createComposerSurface(ctx: ConvCtx): ComposerSurface { if (fastAvailable) fastTitle = fastOn ? "Fast mode active" : "Fast mode"; const approvalPauses = ctx.chat.activePendingApprovals(); const runtimePending = activeRuntimeConfig === null; - const modelToggled = !runtimePending && selectedModel.value !== defaultModelValue(scopeKey()); + const effectiveEffort = + (activeRuntimeConfig?.effective.effortLevel as EffortLevel | undefined) ?? + defaultEffortForModel(selectedModel.model); + const effectiveFast = activeRuntimeConfig?.effective.fastMode === true && fastAvailable; + const runtimeToggled = + !runtimePending && + (selectedModel.value !== defaultModelValue(scopeKey()) || + composerState.effortLevel !== effectiveEffort || + fastOn !== effectiveFast); const inputBlocked = runtimePending || ctx.chat.state.resolvingApprovals.size > 0 || approvalPauses.length > 0; const attachingDisabled = inputBlocked; let placeholder = "Ask anything"; @@ -513,22 +520,22 @@ export function createComposerSurface(ctx: ConvCtx): ComposerSurface { ? settingsControl(agent, selectedModel, inputBlocked) : html` ${ - modelToggled + runtimeToggled ? html`` : nothing } ${ - modelToggled && activeRuntimeConfig?.scopeOverride + runtimeToggled && activeRuntimeConfig?.scopeOverride ? html` + +
`; +} diff --git a/plugins/web-ui/src/session-list.ts b/plugins/web-ui/src/session-list.ts index 6bc352d7..ab3bb21e 100644 --- a/plugins/web-ui/src/session-list.ts +++ b/plugins/web-ui/src/session-list.ts @@ -97,6 +97,24 @@ export function withPendingSession(list: CoreSession[], pending: CoreSession): C return [pending, ...list.filter((s) => s.threadRef !== pending.threadRef)]; } +/** An unsent new chat the user walked away from, with nothing worth keeping. */ +export function isAbandonedNewChat(state: { + threadRef: string | null; + nextThreadRef: string | null; + sessionId: string | null; + pendingSend: string | null; + hasHumanMessage: boolean; + draft: string; + attachments: number; +}): boolean { + const ref = state.threadRef; + if (!ref || ref === state.nextThreadRef) return false; + if (state.sessionId !== null || state.pendingSend === ref) return false; + if (state.hasHumanMessage) return false; + if (state.draft.trim() || state.attachments > 0) return false; + return true; +} + export function withoutUnsentPending(list: CoreSession[], threadRef: string): CoreSession[] { return list.filter((s) => s.id !== "" || s.threadRef !== threadRef); } diff --git a/plugins/web-ui/src/sessions.ts b/plugins/web-ui/src/sessions.ts index bd8f6fd7..738e164f 100644 --- a/plugins/web-ui/src/sessions.ts +++ b/plugins/web-ui/src/sessions.ts @@ -28,6 +28,8 @@ import { api, attachPendingApprovals, fetchTranscript, + currentEarlierCount, + inheritedTranscript, isContinuable, entriesToMessages, regenerateTitle, @@ -40,7 +42,7 @@ import { type CoreProject, type CoreSession, } from "./core-bridge"; -import { sessionLink, UI_BASE } from "./deep-link"; +import { deepLinkPath, isPlainLeftClick, sessionLink, UI_BASE } from "./deep-link"; import { activityOf, chatBrowseStatusMatches, @@ -617,7 +619,15 @@ function chatPageRow(s: CoreSession): TemplateResult { class="list-row chat-row ${active ? "active" : ""} ${s.color ? "colored" : ""}" style=${s.color ? `--session-color:${s.color}` : nothing} > - + ${ s.id ? html` @@ -782,14 +792,19 @@ function sessionRow(s: CoreSession, projectChild = false): TemplateResult { class="session-row ${active ? "active" : ""} ${menuOpen ? "menu-open" : ""} ${readOnly ? "read-only" : ""} ${refreshingTitle ? "title-refreshing" : ""} ${working ? "working" : ""} ${s.awaitingInput ? "awaiting-input" : ""} ${projectChild ? "project-child" : ""} ${s.color ? "colored" : ""}" style=${s.color ? `--session-color:${s.color}` : nothing} > - + ${ saved ? html`
@@ -1218,15 +1233,17 @@ export async function openSessionInto( return; } - const messages = entriesToMessages(entriesRes.entries ?? [], transcriptModel()); - const earlier = entriesRes.earlierEntries ?? 0; + const split = inheritedTranscript(s, entriesRes.entries ?? []); + const messages = entriesToMessages(split.current, transcriptModel()); + const inheritedMessages = entriesToMessages(split.inherited, transcriptModel()); + const earlier = currentEarlierCount(s, entriesRes.earlierEntries ?? 0); const anchorSeq = entriesRes.entries?.[0]?.seq ?? null; if (continuable) { attachPendingApprovals(messages, approvalsRes?.approvals ?? [], transcriptModel()); - conv.mountContinuable(s.threadRef, s.id, s.scopeId, messages, s.channelName ?? null); - conv.setTranscriptWindow(anchorSeq, earlier); + conv.mountContinuable(s.threadRef, s.id, s.scopeId, messages, s.channelName ?? null, s, inheritedMessages); + conv.setTranscriptWindow(anchorSeq, earlier, (entriesRes.earlierEntries ?? 0) > 0); } else { - conv.mountReadOnly(s, messages, earlier, anchorSeq); + conv.mountReadOnly(s, messages, earlier, anchorSeq, inheritedMessages); } renderList(); } diff --git a/plugins/web-ui/src/shell.css b/plugins/web-ui/src/shell.css index 481762f0..e4f6fd67 100644 --- a/plugins/web-ui/src/shell.css +++ b/plugins/web-ui/src/shell.css @@ -8,6 +8,9 @@ --radius-md: 10px; --radius-lg: 16px; --chat-pad: 22px; + /* Height reserved at the bottom of every chat message for its own hover footer; it doubles + as the rhythm between message blocks. */ + --meta-lane: 18px; --brand-accent: #4f46e5; --dev-accent: #8a5a00; --brand-mark: "A"; @@ -235,6 +238,11 @@ body.resizing-sidebar { flex-direction: column; gap: 1px; } +a.navrow, +a.session, +a.chat-row-open { + text-decoration: none; +} .navrow { display: flex; align-items: center; @@ -1065,10 +1073,40 @@ body.resizing-sidebar { min-height: 0; overflow-y: auto; - padding: 28px var(--chat-pad) 26px; + padding: 28px var(--chat-pad) 8px; scroll-behavior: smooth; } +.fork-origin-row { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + margin: 8px 0 14px; +} + +.fork-origin-badge, +.fork-origin-toggle { + border: 1px solid var(--border); + border-radius: 999px; + background: var(--muted); + color: var(--muted-foreground); + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.fork-origin-badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 9px; +} + +.fork-origin-toggle { + padding: 4px 7px; +} + .earlier-messages { display: flex; justify-content: center; @@ -1099,7 +1137,9 @@ body.resizing-sidebar { display: flex; flex-direction: column; - gap: 14px; + /* The space between blocks lives in each row's reserved meta lane (see .message-row), so the + stack adds none of its own — a hover footer can never overhang the block below. */ + gap: 0; } .empty-stack { justify-content: center; @@ -1118,6 +1158,11 @@ body.resizing-sidebar { display: flex; min-width: 0; position: relative; + /* A lane at the bottom of every row that belongs to that row's own hover footer. It doubles + as the rhythm between blocks, so reserving it costs almost no height — and because the + footer paints inside its own row, it can never overlay the block below (most visibly a + collapsed 'Worked for Ns' header). Revealing the footer still changes no layout. */ + padding-bottom: var(--meta-lane); } .user-row { flex-direction: column; @@ -1138,41 +1183,28 @@ body.resizing-sidebar { border: 1px dashed var(--border); } +/* The hover footer (timestamp + copy/fork) sits in its row's reserved bottom lane: out of flow, + so revealing it never reflows, but inside the row's own box, so it never paints over the next + block and needs no opaque backing. The lane is part of the row, so no hover bridge is needed + to reach it either. pointer-events (not visibility) gates interaction, so the buttons stay + keyboard-focusable while hidden — that is what lets :focus-within reveal the footer. */ .message-meta { position: absolute; - top: 100%; + bottom: 0; left: 0; z-index: 2; - margin-top: 2px; display: flex; align-items: center; gap: 6px; - height: 22px; + height: var(--meta-lane); padding: 0 2px; border-radius: 6px; - background: var(--background); color: var(--muted-foreground); font-size: 11px; opacity: 0; pointer-events: none; - /* The ::before bridge below is what keeps the footer reachable; the hide itself stays - near-instant, because the footer overhangs the next row and any lingering paints on - top of content the pointer has already moved on to. pointer-events (not visibility) - gates interaction so the buttons stay keyboard-focusable while hidden, which is what - lets :focus-within reveal the footer at all. */ transition: opacity 0.12s ease; } -/* Invisible hover bridge over the 2px gap between the row and the footer (plus a little - slack), so crossing the gap never drops the row's :hover — while it's crossed, the - footer is a hovered ancestor's child and stays interactive. */ -.message-meta::before { - content: ""; - position: absolute; - left: -4px; - right: -4px; - top: -4px; - height: 4px; -} .user-row .message-meta { left: auto; right: 0; @@ -1186,17 +1218,13 @@ body.resizing-sidebar { pointer-events: auto; } +/* Hoverless (touch) devices can't reveal the footer, and a tap can't focus a control that + ignores pointer events — so there it simply stays visible in its lane. */ @media (hover: none) { .message-meta { - position: static; - margin-top: 2px; - background: transparent; opacity: 1; pointer-events: auto; } - .message-meta::before { - display: none; - } } .message-time { white-space: nowrap; @@ -4112,7 +4140,7 @@ body.resizing-sidebar { padding: var(--surface-safe-top) max(10px, env(safe-area-inset-right)) 0 max(10px, env(safe-area-inset-left)); } .message-stack { - gap: 20px; + gap: 6px; } .chat-scroll { padding-right: max(var(--chat-pad), env(safe-area-inset-right)); @@ -6654,10 +6682,13 @@ h3.ambient-field-label { } [data-density="compact"] .chat-scroll { - padding: 14px 14px 18px; + padding: 14px 14px 4px; +} +[data-density="compact"] .message-row { + --meta-lane: 16px; } [data-density="compact"] .message-stack { - gap: 10px; + gap: 0; font-size: 13.5px; } [data-density="compact"] .live-work-detail { diff --git a/plugins/web-ui/src/shell.ts b/plugins/web-ui/src/shell.ts index ff4ce9a6..1f29ae2b 100644 --- a/plugins/web-ui/src/shell.ts +++ b/plugins/web-ui/src/shell.ts @@ -33,7 +33,7 @@ import { markConnectorConnected } from "./chat"; import { clearSkillsCache, resyncModelSelection, seedRuntimeConfig } from "./composer"; import { ensureDeliveryStream, mainConversation, onExitCanvas } from "./conversations"; import { clearAllDrafts, saveDraft, storedDraft } from "./drafts"; -import { deepLinkPath, parseDeepLink, UI_BASE } from "./deep-link"; +import { deepLinkPath, isPlainLeftClick, parseDeepLink, UI_BASE } from "./deep-link"; import { addBlankPane, canvasToast, @@ -483,14 +483,14 @@ export function mountShell(): void { export function renderSidebarTop(): void { if (!appState.topEl) return; const navRow = (v: View, glyph: IconNode, label: string) => - html``; + `; const navGroup = (id: string, title: string, open: boolean, toggle: () => void, rows: TemplateResult) => html` + + + +
+ + +
+ + + + +`; +} diff --git a/src/deploy/edit-widget.ts b/src/deploy/edit-widget.ts deleted file mode 100644 index df5adaf1..00000000 --- a/src/deploy/edit-widget.ts +++ /dev/null @@ -1,107 +0,0 @@ -export const EDIT_WIDGET_PATH_PREFIX = "/__claw__/"; - -export function editWidgetTag(portalUrl: string, slug: string): string { - const esc = (value: string): string => value.replace(/&/g, "&").replace(/"/g, """).replace(/`; -} - -export const EDIT_WIDGET_JS = `(() => { - const script = document.currentScript; - if (!script || window.__clawEditWidget) return; - window.__clawEditWidget = true; - const portal = script.dataset.portal || ""; - const slug = script.dataset.slug || ""; - if (!portal || !slug) return; - - const host = document.createElement("div"); - const root = host.attachShadow({ mode: "closed" }); - const style = document.createElement("style"); - style.textContent = \` - .bubble { position: fixed; right: 20px; bottom: 20px; z-index: 2147483646; width: 48px; height: 48px; - border-radius: 50%; border: none; cursor: pointer; background: #111; color: #fff; - box-shadow: 0 4px 16px rgba(0,0,0,.28); display: grid; place-items: center; font-size: 21px; - transition: transform .15s ease; } - .bubble:hover { transform: scale(1.08); } - .panel { position: fixed; top: 0; right: 0; bottom: 0; width: min(420px, 92vw); z-index: 2147483647; - background: #fff; box-shadow: -8px 0 28px rgba(0,0,0,.22); display: none; flex-direction: column; } - .panel.open { display: flex; } - .bar { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; - background: #111; color: #fff; font: 13px/1.4 system-ui, sans-serif; } - .bar button { background: none; border: none; color: #fff; font-size: 16px; cursor: pointer; } - iframe { border: 0; flex: 1; width: 100%; } - .reload { position: fixed; right: 84px; bottom: 28px; z-index: 2147483646; display: none; - background: #111; color: #fff; border: none; border-radius: 8px; padding: 8px 12px; - font: 13px system-ui, sans-serif; cursor: pointer; } - .reload.show { display: block; } - \`; - const bubble = document.createElement("button"); - bubble.className = "bubble"; - bubble.type = "button"; - bubble.title = "Edit this app"; - bubble.setAttribute("aria-label", "Edit this app"); - bubble.textContent = "\\u270E"; - const panel = document.createElement("div"); - panel.className = "panel"; - const bar = document.createElement("div"); - bar.className = "bar"; - const title = document.createElement("span"); - title.textContent = "Editing " + slug; - const close = document.createElement("button"); - close.type = "button"; - close.setAttribute("aria-label", "Close editor"); - close.textContent = "\\u2715"; - bar.append(title, close); - panel.append(bar); - const reload = document.createElement("button"); - reload.className = "reload"; - reload.type = "button"; - reload.textContent = "App updated \\u21BB reload"; - root.append(style, bubble, panel, reload); - - let frame = null; - let baseVersion = null; - let timer = null; - - const fetchVersion = async () => { - try { - const r = await fetch("/__claw__/version", { cache: "no-store" }); - if (!r.ok) return null; - const d = await r.json(); - return typeof d.version === "number" ? d.version : null; - } catch { - return null; - } - }; - - const poll = async () => { - const v = await fetchVersion(); - if (v === null) return; - if (baseVersion === null) baseVersion = v; - else if (v !== baseVersion) reload.classList.add("show"); - }; - - const open = () => { - if (!frame) { - frame = document.createElement("iframe"); - frame.src = portal.replace(/\\/$/, "") + "/app-edit?slug=" + encodeURIComponent(slug) + "&embed=1"; - panel.append(frame); - } - panel.classList.add("open"); - bubble.style.display = "none"; - void poll(); - if (!timer) timer = setInterval(poll, 4000); - }; - const shut = () => { - panel.classList.remove("open"); - bubble.style.display = ""; - if (timer) { clearInterval(timer); timer = null; } - }; - bubble.addEventListener("click", open); - close.addEventListener("click", shut); - reload.addEventListener("click", () => location.reload()); - - const mount = () => document.body && document.body.append(host); - if (document.body) mount(); - else document.addEventListener("DOMContentLoaded", mount); -})(); -`; diff --git a/src/harness/opencode-harness.ts b/src/harness/opencode-harness.ts index 6abfe3b0..df75a8e8 100644 --- a/src/harness/opencode-harness.ts +++ b/src/harness/opencode-harness.ts @@ -8,6 +8,8 @@ import { pathToFileURL } from "node:url"; import { spawn, type ChildProcess } from "node:child_process"; import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk"; import { CONFIG_DEFAULTS, type Config } from "../config.ts"; +import { isCustomModelId } from "../model/custom-providers.ts"; +import type { CustomProviderSpec } from "../model/custom-providers.ts"; import { DEFAULT_AGENT_MODEL_ID, resolveModel } from "../model/pi-models.ts"; import { startSignalPoll, type RunSignalStore } from "../runs/run-signal-store.ts"; import type { LlmCallUsage } from "../sessions/session-store.ts"; @@ -44,6 +46,12 @@ export interface OpenCodeHarnessOptions { binaryPath?: string; startupTimeoutMs?: number; tasks?: TaskStore; + /** + * Admin-registered custom providers, resolved (with keys) when the + * opencode server starts. Registrations made while a server is already + * running apply to the next server start. + */ + resolveCustomProviders?: () => Promise>; } export function openCodeHarnessConfigOptions(config: Config): OpenCodeHarnessOptions { @@ -156,7 +164,14 @@ function sessionToken(secret: string, sessionId: string): string { return createHmac("sha256", secret).update(sessionId).digest("base64url"); } -function modelRef(id: string): { providerID: string; modelID: string } { +export function modelRef(id: string): { providerID: string; modelID: string } { + // A registered custom model wins before slash-splitting: gateway model ids + // routinely contain slashes (e.g. "bedrock/claude-x" behind LiteLLM), and + // those must route to the registered provider, not a phantom "bedrock". + if (isCustomModelId(id)) { + const resolved = resolveModel(id); + if (resolved?.provider) return { providerID: String(resolved.provider), modelID: id }; + } const slash = id.indexOf("/"); if (slash > 0) return { providerID: id.slice(0, slash), modelID: id.slice(slash + 1) }; const resolved = resolveModel(id); @@ -628,6 +643,33 @@ export function createOpenCodeHarness(opts: OpenCodeHarnessOptions = {}): Harnes const bridgeUrl = `http://127.0.0.1:${address.port}`; const pluginUrl = pathToFileURL(join(import.meta.dirname, "opencode-plugin.ts")).href; const enabledTools = Object.fromEntries(definitions.map((item) => [item.name, true])); + const custom = (await opts.resolveCustomProviders?.()) ?? []; + const customProviderConfig = Object.fromEntries( + custom.map(({ spec, apiKey }) => [ + spec.id, + { + npm: spec.protocol === "anthropic" ? "@ai-sdk/anthropic" : "@ai-sdk/openai-compatible", + name: spec.name, + options: { baseURL: spec.baseUrl, ...(apiKey ? { apiKey } : {}) }, + models: Object.fromEntries( + spec.models.map((m) => [ + m.id, + { + name: m.name ?? m.id, + ...(m.contextWindow || m.maxTokens + ? { + limit: { + context: m.contextWindow ?? 128_000, + output: m.maxTokens ?? 8_192, + }, + } + : {}), + }, + ]), + ), + }, + ]), + ); const config = { plugin: [pluginUrl], autoupdate: false, @@ -636,10 +678,11 @@ export function createOpenCodeHarness(opts: OpenCodeHarnessOptions = {}): Harnes lsp: false, formatter: false, instructions: [], - enabled_providers: ["anthropic", "openai"], + enabled_providers: ["anthropic", "openai", ...custom.map(({ spec }) => spec.id)], provider: { anthropic: { options: { apiKey: opts.apiKey ?? "" } }, openai: { options: { apiKey: opts.openaiApiKey ?? "" } }, + ...customProviderConfig, }, tools: { ...enabledTools, diff --git a/src/harness/pi-harness.ts b/src/harness/pi-harness.ts index 133429df..4b272c0c 100644 --- a/src/harness/pi-harness.ts +++ b/src/harness/pi-harness.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -48,6 +48,7 @@ import { modelSupportsFastMode, contextTokenBudgetForModel, } from "../model/pi-models.ts"; +import { customModelsJson, customProvidersVersion } from "../model/custom-providers.ts"; import { defineHarness, type Harness, @@ -975,17 +976,40 @@ export interface ProviderKeys { anthropic?: string; openai?: string; openrouter?: string; + /** Admin-registered custom providers, keyed by provider slug. */ + [provider: string]: string | undefined; +} + +// buildModelRuntime runs per turn; the models.json only changes when the +// custom-provider registry does, so cache the materialized file per registry +// version instead of leaking a temp dir per turn. +let cachedCustomModels: { version: number; path: string | null } | null = null; +function customModelsPath(): string | null { + const version = customProvidersVersion(); + if (cachedCustomModels?.version === version) return cachedCustomModels.path; + const custom = customModelsJson(); + let path: string | null = null; + if (custom) { + path = join(mkdtempSync(join(tmpdir(), "pi-custom-models-")), "models.json"); + writeFileSync(path, JSON.stringify(custom)); + } + cachedCustomModels = { version, path }; + return path; } async function buildModelRuntime(keys: ProviderKeys | string): Promise { const k: ProviderKeys = typeof keys === "string" ? { anthropic: keys } : keys; + // Custom providers must exist in the runtime's own registry — a runtime + // API key alone is invisible to its availability checks. models.json is + // the sanctioned vocabulary, so materialize one when any are registered. + const modelsPath = customModelsPath(); const runtime = await ModelRuntime.create({ credentials: new InMemoryCredentialStore(), - modelsPath: null, + modelsPath, }); - if (k.anthropic) await runtime.setRuntimeApiKey("anthropic", k.anthropic, { allowNetwork: false }); - if (k.openai) await runtime.setRuntimeApiKey("openai", k.openai, { allowNetwork: false }); - if (k.openrouter) await runtime.setRuntimeApiKey("openrouter", k.openrouter, { allowNetwork: false }); + for (const [provider, apiKey] of Object.entries(k)) { + if (apiKey) await runtime.setRuntimeApiKey(provider, apiKey, { allowNetwork: false }); + } return runtime; } @@ -1208,8 +1232,7 @@ export function createPiHarness(opts?: PiHarnessOptions): Harness { ...configuredProviderKeys, ...(await opts?.resolveProviderKeys?.()), }); - const keyForModel = (keys: ProviderKeys, model: Model): string | undefined => - keys[model.provider as keyof ProviderKeys]; + const keyForModel = (keys: ProviderKeys, model: Model): string | undefined => keys[String(model.provider)]; const captureRequests = opts?.captureRequests ?? true; const systemCacheSplit = opts?.systemCacheSplit ?? false; const scratchExec = opts?.scratchExec ?? false; diff --git a/src/model/custom-provider-store.ts b/src/model/custom-provider-store.ts new file mode 100644 index 00000000..28528727 --- /dev/null +++ b/src/model/custom-provider-store.ts @@ -0,0 +1,108 @@ +/** + * Durable, encrypted storage for custom model providers. + * + * Mirrors model-credential-store: specs live in a DurableMap, API keys + * are encrypted at rest with a key derived from the connector secret, + * and the store never hands the plaintext key to anything but the + * per-call resolver. + */ + +import { decryptSecret, deriveConnectorKey, encryptSecret } from "../connectors/connector-client-store.ts"; +import type { DurableMap } from "../persistence/durable-map.ts"; +import { validateCustomProviderSpec, type CustomProviderSpec } from "./custom-providers.ts"; + +export interface StoredCustomProvider extends CustomProviderSpec { + apiKeyEnc?: string; + disabled?: boolean; + updatedAt: number; + updatedBy: string; +} + +interface CustomProviderStatus extends CustomProviderSpec { + disabled: boolean; + hasKey: boolean; + updatedAt: number; + updatedBy: string; +} + +export interface CustomProviderStore { + /** Enabled specs only — what the runtime registry should serve. */ + enabled(): Promise; + /** Everything, for the admin surface (no secrets). */ + statuses(): Promise; + /** Plaintext key for one provider, or null when absent/disabled. */ + resolveKey(id: string): Promise; + upsert(spec: CustomProviderSpec, apiKey: string | undefined, updatedBy: string): Promise; + delete(id: string, updatedBy: string): Promise; +} + +function strip(saved: StoredCustomProvider): CustomProviderSpec { + return { + id: saved.id, + name: saved.name, + protocol: saved.protocol, + baseUrl: saved.baseUrl, + models: saved.models, + }; +} + +export function createCustomProviderStore(input: { + backing: DurableMap; + keyMaterial: string | Buffer; +}): CustomProviderStore { + const key = deriveConnectorKey(input.keyMaterial, "custom-model-providers"); + + return { + async enabled() { + const all = await input.backing.all(); + return all.filter((p) => !p.disabled).map(strip); + }, + + async statuses() { + const all = await input.backing.all(); + return all + .map((p) => ({ + ...strip(p), + disabled: p.disabled ?? false, + hasKey: Boolean(p.apiKeyEnc), + updatedAt: p.updatedAt, + updatedBy: p.updatedBy, + })) + .sort((a, b) => a.id.localeCompare(b.id)); + }, + + async resolveKey(id) { + const saved = await input.backing.get(id); + if (!saved || saved.disabled || !saved.apiKeyEnc) return null; + return decryptSecret(saved.apiKeyEnc, key); + }, + + async upsert(spec, apiKey, updatedBy) { + validateCustomProviderSpec(spec); + const actor = updatedBy.trim(); + if (!actor) throw new Error("updatedBy is required"); + const existing = await input.backing.get(spec.id); + const trimmedKey = apiKey?.trim(); + const apiKeyEnc = trimmedKey ? encryptSecret(trimmedKey, key) : existing?.apiKeyEnc; + await input.backing.put(spec.id, { + ...spec, + ...(apiKeyEnc ? { apiKeyEnc } : {}), + disabled: false, + updatedAt: Date.now(), + updatedBy: actor, + }); + }, + + async delete(id, updatedBy) { + const existing = await input.backing.get(id); + if (!existing || existing.disabled) return false; + await input.backing.put(id, { + ...existing, + disabled: true, + updatedAt: Date.now(), + updatedBy, + }); + return true; + }, + }; +} diff --git a/src/model/custom-providers.ts b/src/model/custom-providers.ts new file mode 100644 index 00000000..cb2a92c2 --- /dev/null +++ b/src/model/custom-providers.ts @@ -0,0 +1,182 @@ +/** + * Custom model providers. + * + * An org admin can register additional model providers that speak one of + * the two wire protocols we already run — OpenAI-compatible or + * Anthropic-compatible — by giving a base URL, an API key, and the model + * ids to expose. Registered models resolve like built-ins (the pi + * harness reaches them through the same request path), surface in the + * catalog, and are gated to harnesses that route through pi-ai. + * + * Secrets never live here: this module holds the runtime registry + * (everything except the key). Keys stay in the encrypted store and are + * resolved per-call by wiring alongside the built-in provider keys. + */ + +import { parseProviderBaseUrl, PROVIDER_IDS } from "./provider-endpoints.ts"; + +export const CUSTOM_PROVIDER_PROTOCOLS = ["openai", "anthropic"] as const; +export type CustomProviderProtocol = (typeof CUSTOM_PROVIDER_PROTOCOLS)[number]; + +interface CustomModelSpec { + id: string; + name?: string; + contextWindow?: number; + maxTokens?: number; + /** USD per million input tokens. Defaults to 0 (unknown / not metered). */ + input?: number; + /** USD per million output tokens. Defaults to 0. */ + output?: number; +} + +export interface CustomProviderSpec { + /** Slug: lowercase, digits, hyphens; also the model's `provider` value. */ + id: string; + name: string; + protocol: CustomProviderProtocol; + baseUrl: string; + models: CustomModelSpec[]; +} + +const SLUG_RE = /^[a-z][a-z0-9-]{1,31}$/; +const RESERVED = new Set([...PROVIDER_IDS, "mock"]); + +export function validateCustomProviderSpec(spec: CustomProviderSpec): void { + if (!SLUG_RE.test(spec.id)) { + throw new Error(`provider id must match ${SLUG_RE} (lowercase slug), got "${spec.id}"`); + } + if (RESERVED.has(spec.id)) throw new Error(`provider id "${spec.id}" is reserved`); + if (!spec.name.trim()) throw new Error("provider name is required"); + if (spec.name.length > 100) throw new Error("provider name must be 100 chars or fewer"); + if (!CUSTOM_PROVIDER_PROTOCOLS.includes(spec.protocol)) { + throw new Error(`protocol must be one of ${CUSTOM_PROVIDER_PROTOCOLS.join(", ")}`); + } + parseProviderBaseUrl(`custom provider ${spec.id} baseUrl`, spec.baseUrl); + if (!Array.isArray(spec.models) || spec.models.length === 0) { + throw new Error("at least one model is required"); + } + if (spec.models.length > 200) throw new Error("at most 200 models per provider"); + const seen = new Set(); + for (const m of spec.models) { + if (!m.id?.trim() || m.id.length > 200) throw new Error("every model needs an id (<=200 chars)"); + if (m.name !== undefined && (typeof m.name !== "string" || m.name.length > 200)) + throw new Error(`model "${m.id}": name must be a string of 200 chars or fewer`); + if (seen.has(m.id)) throw new Error(`duplicate model id "${m.id}"`); + seen.add(m.id); + for (const [field, v] of [ + ["contextWindow", m.contextWindow], + ["maxTokens", m.maxTokens], + ["input", m.input], + ["output", m.output], + ] as const) { + if (v !== undefined && (typeof v !== "number" || !Number.isFinite(v) || v < 0)) { + throw new Error(`model "${m.id}": ${field} must be a non-negative number`); + } + } + } +} + +/** + * The wire-level shape pi-ai expects. We construct these without + * importing pi-ai so this module stays dependency-free; pi-models casts + * at its boundary, the same way it treats getBuiltinModel. + */ +export interface CustomRuntimeModel { + id: string; + name: string; + provider: string; + api: "openai-completions" | "anthropic-messages"; + baseUrl: string; + reasoning: boolean; + input: ("text" | "image")[]; + cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; + contextWindow: number; + maxTokens: number; +} + +const DEFAULT_CONTEXT_WINDOW = 128_000; +const DEFAULT_MAX_TOKENS = 8_192; + +function toRuntimeModel(provider: CustomProviderSpec, m: CustomModelSpec): CustomRuntimeModel { + return { + id: m.id, + name: m.name?.trim() || m.id, + provider: provider.id, + api: provider.protocol === "anthropic" ? "anthropic-messages" : "openai-completions", + baseUrl: provider.baseUrl, + reasoning: false, + input: ["text"], + cost: { input: m.input ?? 0, output: m.output ?? 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: m.contextWindow ?? DEFAULT_CONTEXT_WINDOW, + maxTokens: m.maxTokens ?? DEFAULT_MAX_TOKENS, + }; +} + +let registry = new Map(); +let providers: CustomProviderSpec[] = []; +let version = 0; + +/** + * Called by wiring at boot and again after every admin write, with the + * full current set of enabled providers. Last write wins; built-in model + * ids shadow custom ones at resolution, so a collision can't hijack a + * built-in. + */ +export function setCustomProviders(specs: CustomProviderSpec[]): void { + const next = new Map(); + for (const spec of specs) { + for (const m of spec.models) { + next.set(m.id, toRuntimeModel(spec, m)); + } + } + registry = next; + providers = specs.map((s) => ({ ...s, models: [...s.models] })); + version += 1; +} + +/** Bumps on every registry change — lets callers cache derived artifacts. */ +export function customProvidersVersion(): number { + return version; +} + +export function resolveCustomModel(id: string): CustomRuntimeModel | undefined { + return registry.get(id); +} + +export function isCustomModelId(id: string): boolean { + return registry.has(id); +} + +export function customModelCatalog(): Array<{ id: string; name: string; provider: string }> { + return [...registry.values()].map((m) => ({ id: m.id, name: m.name, provider: m.provider })); +} + +/** + * The models.json fragment pi-coding-agent understands. Materialized to a + * temp file whenever the pi harness builds a model runtime, so the + * runtime's own provider registry knows each custom provider natively — + * a runtime API key alone is not enough (availability checks only cover + * providers the ModelsStore knows). + */ +export function customModelsJson(): { providers: Record } | undefined { + if (providers.length === 0) return undefined; + return { + providers: Object.fromEntries( + providers.map((spec) => [ + spec.id, + { + name: spec.name, + baseUrl: spec.baseUrl, + api: spec.protocol === "anthropic" ? "anthropic-messages" : "openai-completions", + models: spec.models.map((m) => ({ + id: m.id, + name: m.name ?? m.id, + contextWindow: m.contextWindow ?? 128_000, + maxTokens: m.maxTokens ?? 8_192, + cost: { input: m.input ?? 0, output: m.output ?? 0, cacheRead: 0, cacheWrite: 0 }, + })), + }, + ]), + ), + }; +} diff --git a/src/model/model-catalog.ts b/src/model/model-catalog.ts index 812d69c9..77a26440 100644 --- a/src/model/model-catalog.ts +++ b/src/model/model-catalog.ts @@ -1,9 +1,11 @@ import { modelSupportedByHarness, resolveModel, SELECTABLE_BASE_MODELS } from "./pi-models.ts"; +import { customModelCatalog, customProvidersVersion } from "./custom-providers.ts"; export interface ModelCatalogEntry { id: string; name: string; - provider: "anthropic" | "openai" | "openrouter"; + /** A built-in provider or the slug of an admin-registered custom provider. */ + provider: string; } const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models?supported_parameters=tools&sort=most-popular"; @@ -14,6 +16,7 @@ const CACHE_TTL_MS = 5 * 60_000; const FAILURE_TTL_MS = 30_000; interface CacheEntry { + customVersion?: number; expiresAt: number; models: ModelCatalogEntry[]; inFlight?: Promise; @@ -22,12 +25,14 @@ interface CacheEntry { const cache = new WeakMap(); export function builtInModelCatalog(): ModelCatalogEntry[] { - return SELECTABLE_BASE_MODELS.flatMap((model) => { + const builtIns = SELECTABLE_BASE_MODELS.flatMap((model) => { const provider = resolveModel(model.id)?.provider; return provider === "anthropic" || provider === "openai" || provider === "openrouter" - ? [{ ...model, provider }] + ? [{ ...model, provider: provider as string }] : []; }); + const known = new Set(builtIns.map((model) => model.id)); + return [...builtIns, ...customModelCatalog().filter((model) => !known.has(model.id))]; } async function boundedJson(response: Response): Promise { @@ -77,7 +82,10 @@ async function fetchOpenRouterModels(fetcher: typeof fetch): Promise { const now = Date.now(); const existing = cache.get(fetcher); - if (existing && existing.expiresAt > now) return existing.models; + // A registry change (admin registered/removed a custom provider) must be + // visible in the next picker load, not after the TTL runs out. + if (existing && existing.expiresAt > now && existing.customVersion === customProvidersVersion()) + return existing.models; if (existing?.inFlight) return existing.inFlight; const entry = existing ?? { expiresAt: 0, models: [] }; entry.inFlight = fetchOpenRouterModels(fetcher) @@ -86,11 +94,13 @@ export async function selectableModelCatalog(fetcher: typeof fetch = fetch): Pro const known = new Set(models.map((model) => model.id)); entry.models = [...models, ...dynamic.filter((model) => !known.has(model.id))]; entry.expiresAt = Date.now() + CACHE_TTL_MS; + entry.customVersion = customProvidersVersion(); return entry.models; }) .catch(() => { entry.models = entry.models.length ? entry.models : builtInModelCatalog(); entry.expiresAt = Date.now() + FAILURE_TTL_MS; + entry.customVersion = customProvidersVersion(); return entry.models; }) .finally(() => { diff --git a/src/model/pi-models.ts b/src/model/pi-models.ts index a57462ef..7d193eec 100644 --- a/src/model/pi-models.ts +++ b/src/model/pi-models.ts @@ -1,5 +1,7 @@ import { getBuiltinModel } from "@earendil-works/pi-ai/providers/all"; import type { Api, Model } from "@earendil-works/pi-ai"; +import { providerBaseUrl } from "./provider-endpoints.ts"; +import { isCustomModelId, resolveCustomModel } from "./custom-providers.ts"; const getModel = getBuiltinModel as unknown as (provider: string, id: string) => Model | undefined; @@ -106,7 +108,12 @@ export const SELECTABLE_BASE_MODELS: ReadonlyArray<{ id: string; name: string }> function builtinModel(id: string): PiModel | undefined { for (const provider of MODEL_PROVIDERS) { const m = getModel(provider, id); - if (m) return m; + if (!m) continue; + // Endpoint overrides apply here, at the single choke point every + // resolution passes through — including clones, whose template is + // spread by cloneModel, so an overridden template covers its clones. + const override = providerBaseUrl(String(m.provider ?? provider)); + return override ? { ...m, baseUrl: override } : m; } return undefined; } @@ -142,7 +149,7 @@ export function resolveModel(id: string): PiModel | undefined { }) : undefined; } - return builtinModel(id); + return builtinModel(id) ?? (resolveCustomModel(id) as unknown as PiModel | undefined); } export function auxiliaryModelForProvider(provider: string): string | undefined { @@ -168,6 +175,8 @@ export function contextTokenBudgetForModel(id: string): number | undefined { export function modelSupportedByHarness(id: string | undefined, harness: string): boolean { if (!id) return false; + if (isCustomModelId(id) && !REGISTRY_BY_ID.has(id)) + return harness === "pi" || harness === "opencode" || harness === "mock"; if (harness === "pi" || harness === "opencode" || harness === "mock") return Boolean(resolveModel(id)); const provider = resolveModel(id)?.provider; if (harness === "claude") return provider === "anthropic" || /^claude-/i.test(id); @@ -198,6 +207,7 @@ export interface ModelProviderAvailability { export function modelServiceable(id: string, providers: ModelProviderAvailability): boolean { const provider = resolveModel(id)?.provider; if (!provider) return false; + if (isCustomModelId(id) && !REGISTRY_BY_ID.has(id)) return true; if (provider === "openai") return providers.openai; if (provider === "anthropic") return providers.anthropic; if (provider === "openrouter") return providers.openrouter; diff --git a/src/model/provider-endpoints.ts b/src/model/provider-endpoints.ts new file mode 100644 index 00000000..d5389cd6 --- /dev/null +++ b/src/model/provider-endpoints.ts @@ -0,0 +1,65 @@ +/** + * Provider endpoint overrides. + * + * One place decides which base URL each model provider is reached at. + * Config parses and validates the `*_BASE_URL` environment variables, + * wiring injects them here, and everything that issues a request — the + * in-process pi harness, the child-process harness environments, and + * admin API-key validation — resolves through this module. No harness + * reads its own override. + */ + +export const PROVIDER_IDS = ["anthropic", "openai", "openrouter"] as const; +type ProviderId = (typeof PROVIDER_IDS)[number]; + +const PROVIDER_BASE_URL_ENV: Record = { + anthropic: "ANTHROPIC_BASE_URL", + openai: "OPENAI_BASE_URL", + openrouter: "OPENROUTER_BASE_URL", +}; + +export type ProviderBaseUrls = Partial>; + +/** + * Validate and normalize a provider base URL. Returns the normalized + * origin+path with trailing slashes removed. Throws on anything that + * would silently misroute requests: non-HTTP(S) schemes, embedded + * credentials, query strings, and fragments. + */ +export function parseProviderBaseUrl(envName: string, value: string): string { + const trimmed = value.trim().replace(/\/+$/, ""); + let url: URL; + try { + url = new URL(trimmed); + } catch { + throw new Error(`${envName} is not a valid URL: ${value}`); + } + if (url.protocol !== "http:" && url.protocol !== "https:") + throw new Error(`${envName} must be an http(s) URL, got ${url.protocol}//`); + if (url.username || url.password) throw new Error(`${envName} must not contain credentials`); + if (url.search) throw new Error(`${envName} must not contain a query string`); + if (url.hash) throw new Error(`${envName} must not contain a fragment`); + return trimmed; +} + +export function providerBaseUrlsFromEnv(env: NodeJS.ProcessEnv): ProviderBaseUrls { + const urls: ProviderBaseUrls = {}; + for (const provider of PROVIDER_IDS) { + const envName = PROVIDER_BASE_URL_ENV[provider]; + const raw = env[envName]; + if (raw?.trim()) urls[provider] = parseProviderBaseUrl(envName, raw); + } + return urls; +} + +let configured: ProviderBaseUrls = {}; + +/** Called once by wiring with the config-parsed overrides. */ +export function setProviderBaseUrls(urls: ProviderBaseUrls): void { + configured = { ...urls }; +} + +/** The override for a provider, if one is configured. */ +export function providerBaseUrl(provider: string): string | undefined { + return (PROVIDER_IDS as readonly string[]).includes(provider) ? configured[provider as ProviderId] : undefined; +} diff --git a/src/resolution/config-store.ts b/src/resolution/config-store.ts index 2b0fe16e..754afd0b 100644 --- a/src/resolution/config-store.ts +++ b/src/resolution/config-store.ts @@ -63,10 +63,14 @@ export interface PersistedBaseModel { harnessId?: string; orgRevision?: number; revision?: number; + effortLevel?: string; + fastMode?: boolean; } interface RuntimeSelection { harnessId: string; modelId: string; + effortLevel?: string; + fastMode?: boolean; } interface ScopedRuntimeSelection extends RuntimeSelection { orgRevision: number; @@ -652,6 +656,8 @@ export function createMemoryConfigStore( modelId: row.modelId, orgRevision: row.orgRevision ?? 0, ...(row.revision !== undefined ? { revision: row.revision } : {}), + ...(row.effortLevel !== undefined ? { effortLevel: row.effortLevel } : {}), + ...(row.fastMode !== undefined ? { fastMode: row.fastMode } : {}), }; }, setRuntimeSelection(id, selection) { @@ -718,6 +724,8 @@ export function createMemoryConfigStore( modelId: row.modelId, orgRevision: row.orgRevision ?? 0, ...(row.revision !== undefined ? { revision: row.revision } : {}), + ...(row.effortLevel !== undefined ? { effortLevel: row.effortLevel } : {}), + ...(row.fastMode !== undefined ? { fastMode: row.fastMode } : {}), }; }, getApprovedHarnesses: () => (approvedHarnesses ? [...approvedHarnesses] : null), diff --git a/src/sessions/memory-session-store.ts b/src/sessions/memory-session-store.ts index 79745801..f1f1986c 100644 --- a/src/sessions/memory-session-store.ts +++ b/src/sessions/memory-session-store.ts @@ -101,6 +101,11 @@ export function createMemorySessionStore(opts: StoreOptions = {}): SessionStore if (s) s.title = title; }, + async updateForkProvenance(sessionId, provenance) { + const s = sessions.get(sessionId); + if (s) Object.assign(s, provenance); + }, + async acquireLease(sessionId, holder): Promise { const held = leases.get(sessionId); if (held && now() < held.expiresAt) diff --git a/src/sessions/postgres-session-store.ts b/src/sessions/postgres-session-store.ts index 7506f25b..75942126 100644 --- a/src/sessions/postgres-session-store.ts +++ b/src/sessions/postgres-session-store.ts @@ -36,7 +36,7 @@ import { userMessagePreview, } from "./session-store.ts"; -function rowToSession(r: Record): Session { +export function rowToSession(r: Record): Session { return { id: r.id as string, type: r.type as SessionType, @@ -46,6 +46,15 @@ function rowToSession(r: Record): Session { createdAt: Number(r.created_at), ...(r.title != null ? { title: r.title as string } : {}), ...(r.channel_name != null ? { channelName: r.channel_name as string } : {}), + ...(r.forked_from_session_id != null && r.fork_boundary_seq != null + ? { + forkedFrom: { + sessionId: r.forked_from_session_id as string, + ...(r.forked_from_title != null ? { title: r.forked_from_title as string } : {}), + }, + forkBoundarySeq: Number(r.fork_boundary_seq), + } + : {}), }; } @@ -169,6 +178,15 @@ export function createPostgresSessionStore(connectionString: string, opts: Store `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS last_activity BIGINT`, `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS messages INT`, `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS turns INT`, + `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS forked_from_session_id TEXT`, + `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS forked_from_title TEXT`, + `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS fork_boundary_seq INT`, + `DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'sessions_fork_provenance_pair') THEN + ALTER TABLE sessions ADD CONSTRAINT sessions_fork_provenance_pair + CHECK ((forked_from_session_id IS NULL) = (fork_boundary_seq IS NULL)) NOT VALID; + END IF; + END $$`, `CREATE TABLE IF NOT EXISTS session_entries( session_id TEXT NOT NULL, seq INT NOT NULL, parent_seq INT, type TEXT NOT NULL, payload TEXT, scope_label TEXT NOT NULL, created_at BIGINT NOT NULL, @@ -343,6 +361,13 @@ export function createPostgresSessionStore(connectionString: string, opts: Store await q("UPDATE sessions SET title = $2 WHERE id = $1", [sessionId, title]); }, + async updateForkProvenance(sessionId, provenance): Promise { + await q( + "UPDATE sessions SET forked_from_session_id = $2, forked_from_title = $3, fork_boundary_seq = $4 WHERE id = $1", + [sessionId, provenance.forkedFrom.sessionId, provenance.forkedFrom.title ?? null, provenance.forkBoundarySeq], + ); + }, + async acquireLease(sessionId, holder): Promise { const token = randomUUID(); const t = now(); diff --git a/src/sessions/session-store.ts b/src/sessions/session-store.ts index 9b5a544d..6f056d52 100644 --- a/src/sessions/session-store.ts +++ b/src/sessions/session-store.ts @@ -416,6 +416,10 @@ export interface SessionStore { get(sessionId: string): Promise; updateTitle(sessionId: string, title: string): Promise; + updateForkProvenance( + sessionId: string, + provenance: { forkedFrom: { sessionId: string; title?: string | null }; forkBoundarySeq: number }, + ): Promise; acquireLease(sessionId: string, holder?: LeaseHolder): Promise; releaseLease(lease: Lease): Promise; diff --git a/src/types.ts b/src/types.ts index 99d9aff7..1f76af2d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -72,6 +72,8 @@ export interface Session { archived?: boolean; pinned?: boolean; color?: string; + forkedFrom?: { sessionId: string; title?: string | null }; + forkBoundarySeq?: number; lastActivityAt?: number; hasEntries?: boolean; working?: boolean; diff --git a/src/wiring.ts b/src/wiring.ts index f2a93ade..83540e3d 100644 --- a/src/wiring.ts +++ b/src/wiring.ts @@ -158,6 +158,9 @@ import { createPostgresEgressAuditSink } from "./admin/postgres-egress-audit-sin import { createConsentLinkStore, type ConsentLinkStore, type ConsentLinkRecord } from "./connectors/consent-link.ts"; import { createModelGateway, type ModelGateway } from "./model/model-gateway.ts"; import { createModelCredentialStore, type ModelCredentialStore } from "./model/model-credential-store.ts"; +import { setProviderBaseUrls } from "./model/provider-endpoints.ts"; +import { setCustomProviders } from "./model/custom-providers.ts"; +import { createCustomProviderStore, type CustomProviderStore } from "./model/custom-provider-store.ts"; import { createMemorySessionStore } from "./sessions/memory-session-store.ts"; import { createPostgresSessionStore } from "./sessions/postgres-session-store.ts"; import type { SessionStore } from "./sessions/session-store.ts"; @@ -318,6 +321,8 @@ export interface BuiltApp { secretDrops: SecretDropStore; modelGateway: ModelGateway; modelCredentials: ModelCredentialStore; + customProviders: CustomProviderStore; + refreshCustomProviders: () => Promise; acl: AclStore; skills: SkillStore; skillBundles: SkillBundleStore; @@ -394,6 +399,7 @@ export function buildApp( const pgArtifactMap = config.databaseUrl ? createPostgresMapFactory(config.databaseUrl) : null; const artifactMap = (table: string): DurableMap => pgArtifactMap ? pgArtifactMap.map(table) : createMemoryMap(); + setProviderBaseUrls(config.providerBaseUrls); const modelCredentials = createModelCredentialStore({ backing: artifactMap("model_credentials"), keyMaterial: config.connectorSecretKey ?? randomBytes(32), @@ -681,16 +687,44 @@ export function buildApp( ? createPostgresRunSignalStore(requireDbUrl("RUN_STORE")) : createMemoryRunSignalStore(); const tasks = config.databaseUrl ? createPostgresTaskStore(config.databaseUrl) : createMemoryTaskStore(); + const customProviders = createCustomProviderStore({ + backing: artifactMap("custom_model_providers"), + keyMaterial: config.connectorSecretKey ?? randomBytes(32), + }); + const refreshCustomProviders = async () => { + setCustomProviders(await customProviders.enabled()); + }; + void refreshCustomProviders().catch((e) => + console.error("[wiring] custom provider hydration failed:", errMessage(e)), + ); const resolveModelProviderKeys = async () => { - const [anthropic, openai, openrouter] = await Promise.all([ + const [anthropic, openai, openrouter, enabledCustom] = await Promise.all([ modelCredentials.resolve("anthropic"), modelCredentials.resolve("openai"), modelCredentials.resolve("openrouter"), + customProviders.enabled(), ]); + const customKeys = Object.fromEntries( + ( + await Promise.all( + enabledCustom.map(async (p) => { + try { + return [p.id, await customProviders.resolveKey(p.id)] as const; + } catch (e) { + // A corrupt/undecryptable custom key must degrade that one + // provider, never the whole turn (built-ins included). + console.error(`[model] custom provider ${p.id}: key unreadable: ${errMessage(e)}`); + return [p.id, null] as const; + } + }), + ) + ).filter(([, key]) => key), + ); return { ...(anthropic ? { anthropic } : {}), ...(openai ? { openai } : {}), ...(openrouter ? { openrouter } : {}), + ...customKeys, }; }; const runtimeOrgScope = scopeId("org", config.orgId); @@ -706,7 +740,31 @@ export function buildApp( signals: runSignals, }), ], - ["opencode", createOpenCodeHarness({ ...openCodeHarnessConfigOptions(config), signals: runSignals, tasks })], + [ + "opencode", + createOpenCodeHarness({ + ...openCodeHarnessConfigOptions(config), + signals: runSignals, + tasks, + resolveCustomProviders: async () => { + const enabled = await customProviders.enabled(); + return Promise.all( + enabled.map(async (spec) => { + try { + const apiKey = await customProviders.resolveKey(spec.id); + return { spec, ...(apiKey ? { apiKey } : {}) }; + } catch (e) { + // An unreadable key must not prevent the opencode server from + // starting; the provider is configured keyless and its models + // fail individually instead. + console.error(`[model] custom provider ${spec.id}: key unreadable: ${errMessage(e)}`); + return { spec }; + } + }), + ); + }, + }), + ], ["codex", createCodexHarness({ ...codexHarnessConfigOptions(config), signals: runSignals, tasks })], ["claude", createClaudeHarness({ ...claudeHarnessConfigOptions(config), signals: runSignals, tasks })], ["mock", createMockHarness()], @@ -1049,6 +1107,8 @@ export function buildApp( tasks, modelGateway, modelCredentials, + customProviders, + refreshCustomProviders, ...(overrides.modelCredentialFetch ? { modelCredentialFetch: overrides.modelCredentialFetch } : {}), acl, admin, @@ -1383,6 +1443,8 @@ export function buildApp( secretDrops, modelGateway, modelCredentials, + customProviders, + refreshCustomProviders, acl, skills, skillBundles, diff --git a/test/admin-resources.test.ts b/test/admin-resources.test.ts index e34fa317..c0bb8545 100644 --- a/test/admin-resources.test.ts +++ b/test/admin-resources.test.ts @@ -259,10 +259,25 @@ test("runtime-config lets a person set, keep, and inherit an approved personal r const set = await fetch(`${srv.base}/v1/runtime-config`, { method: "PUT", headers: { "content-type": "application/json" }, - body: JSON.stringify({ principalId: "alice", scopeId: "personal:alice", harnessId: "codex", modelId: "gpt-5.5" }), + body: JSON.stringify({ + principalId: "alice", + scopeId: "personal:alice", + harnessId: "codex", + modelId: "gpt-5.5", + effortLevel: "low", + fastMode: true, + }), }); assert.equal(set.status, 200); - assert.equal(((await set.json()) as { effective: { harnessId: string } }).effective.harnessId, "codex"); + const selected = (await set.json()) as { + effective: { harnessId: string; effortLevel: string; fastMode: boolean }; + }; + assert.deepEqual(selected.effective, { + harnessId: "codex", + modelId: "gpt-5.5", + effortLevel: "low", + fastMode: false, + }); srv.built.config.setRuntimeSelection("org:default-org", { harnessId: "claude", modelId: "claude-opus-4-8" }); await srv.built.config.flushScope("org:default-org"); diff --git a/test/agent-conversations-route.test.ts b/test/agent-conversations-route.test.ts index 3e5f3028..ba06956d 100644 --- a/test/agent-conversations-route.test.ts +++ b/test/agent-conversations-route.test.ts @@ -22,9 +22,9 @@ describe("agent conversations self-API", async () => { let mineId: string; let theirsId: string; - const capFor = (actorId: string, scope = scopeId("personal", actorId)) => + const capFor = (actorId: string, scope = scopeId("personal", actorId), live = true) => mintCapabilityToken( - { actorId, scopeId: scope, aud: CONTROL_PLANE_AUD, exp: Date.now() + CAPABILITY_TTL_MS }, + { actorId, scopeId: scope, aud: CONTROL_PLANE_AUD, exp: Date.now() + CAPABILITY_TTL_MS, liveActor: live }, SECRET, ); @@ -50,6 +50,58 @@ describe("agent conversations self-API", async () => { await new Promise((resolve) => server.close(() => resolve())); }); + it("spawns a fresh conversation with only the seed text", async () => { + const token = await capFor("U1"); + const res = await post( + "/v1/conversations", + { text: "investigate the flaky test", title: "Flaky test hunt" }, + token, + ); + assert.equal(res.status, 202); + const body = (await res.json()) as { + session: { id: string; scopeId: string; threadRef: string; title?: string | null }; + turn: { status: string; runId?: string }; + }; + assert.notEqual(body.session.id, mineId); + assert.equal(body.session.scopeId, scopeId("personal", "U1")); + assert.equal(body.session.title, "Flaky test hunt"); + const list = await get("/v1/conversations", token); + const { conversations } = (await list.json()) as { conversations: Array<{ id: string }> }; + assert.ok( + conversations.some((c) => c.id === body.session.id), + "the spawned session appears in the actor's list", + ); + assert.equal(body.turn.status, "queued"); + assert.ok(body.turn.runId, "the seed turn is queued as a run"); + const run = await built.runs.get(body.turn.runId!); + assert.equal(run?.request.text, "investigate the flaky test"); + assert.equal(run?.request.conversation.threadRef, body.session.threadRef, "the seed runs in the new session"); + const read = await get(`/v1/conversations/${body.session.id}`, token); + assert.equal(read.status, 200); + const readBody = (await read.json()) as { entries: Array<{ payload: { text?: string } }> }; + assert.ok( + !readBody.entries.some((e) => (e.payload.text ?? "").includes("plan the launch")), + "nothing from the spawning conversation leaks in", + ); + }); + + it("spawn requires text and a capability", async () => { + assert.equal((await post("/v1/conversations", { text: "hi" })).status, 401); + assert.equal((await post("/v1/conversations", {}, await capFor("U1"))).status, 400); + assert.equal((await post("/v1/conversations", { text: " " }, await capFor("U1"))).status, 400); + }); + + it("spawn refuses an unattended (automation) turn", async () => { + const token = await capFor("U1", scopeId("personal", "U1"), false); + const res = await post("/v1/conversations", { text: "cron trying to spawn" }, token); + assert.equal(res.status, 403); + }); + + it("spawn refuses a scope the actor doesn't own", async () => { + const token = await capFor("U1", scopeId("personal", "U2")); + assert.equal((await post("/v1/conversations", { text: "peek" }, token)).status, 404); + }); + it("requires a capability token", async () => { assert.equal((await get("/v1/conversations")).status, 401); assert.equal((await get(`/v1/conversations/${mineId}`)).status, 401); diff --git a/test/credential-broker.test.ts b/test/credential-broker.test.ts index ab4266c5..8e2f9dfd 100644 --- a/test/credential-broker.test.ts +++ b/test/credential-broker.test.ts @@ -218,6 +218,39 @@ test("WHAT path allowlist: an out-of-allowlist path is refused", async () => { assert.equal(cap.calls.length, 0); }); +test("WHAT path allowlist: percent-encoded parent traversal never escapes the prefix", async () => { + const mk = () => reader({ slug: "x-firehose", allowedPathPrefixes: ["/2/tweets/search"] }); + const evil = [ + "https://api.x.com/2/tweets/search/../secrets", // literal + "https://api.x.com/2/tweets/search/..%2fsecrets", // encoded slash + "https://api.x.com/2/tweets/search/%2e%2e/secrets", // encoded dots (URL-normalized out of prefix) + "https://api.x.com/2/tweets/search/%2e%2e%2fsecrets", // fully encoded + "https://api.x.com/2/tweets/search/%252e%252e%252fsecrets", // double-encoded + "https://api.x.com/2/tweets/search/..%5csecrets", // encoded backslash + "https://api.x.com/2/tweets/search/%zz", // undecodable + ]; + for (const url of evil) { + const cap = captureFetch(); + const r = await brokerCredentialCall( + base({ reader: mk(), body: { credential: "x-firehose", url }, fetchImpl: cap.fetch }), + ); + assert.equal(r.status, 403, url); + assert.match(JSON.stringify(r.json), /path_not_allowed/, url); + assert.equal(cap.calls.length, 0, `no upstream fetch for ${url}`); + } + // benign lookalikes still pass: dots inside a segment are not dot segments + const cap = captureFetch(); + const ok = await brokerCredentialCall( + base({ + reader: mk(), + body: { credential: "x-firehose", url: "https://api.x.com/2/tweets/search/v1..v2/some%20file" }, + fetchImpl: cap.fetch, + }), + ); + assert.equal(ok.status, 200); + assert.equal(cap.calls.length, 1); +}); + test("a disabled credential is refused even when the token still names it (live re-check)", async () => { const cap = captureFetch(); const r = await brokerCredentialCall( diff --git a/test/custom-provider-e2e.test.ts b/test/custom-provider-e2e.test.ts new file mode 100644 index 00000000..cc51db83 --- /dev/null +++ b/test/custom-provider-e2e.test.ts @@ -0,0 +1,334 @@ +// QA: end-to-end custom-provider lifecycle against a REAL fake upstream. +// Boots the app, registers a provider pointing at a local OpenAI-compatible +// server, and proves: validation, catalog surfacing, key hygiene, a real +// model call leaving QM and hitting the endpoint, edit-without-key, delete. +import "./support/auto-fake-sprites.ts"; +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { createInsecureTestServer } from "../src/api/server.ts"; +import { buildApp } from "../src/wiring.ts"; +import { testConfig } from "./support/test-config.ts"; +import { oneShot } from "../src/harness/pi-harness.ts"; +import { resolveModel, modelSupportedByHarness, modelServiceable } from "../src/model/pi-models.ts"; +import { setCustomProviders } from "../src/model/custom-providers.ts"; +import { createCustomProviderStore } from "../src/model/custom-provider-store.ts"; +import { createMemoryMap } from "../src/persistence/durable-map.ts"; +import type { Api, Model } from "@earendil-works/pi-ai"; + +const ADMIN = { "content-type": "application/json", "x-admin-actor": "admin-alice@default-org" }; + +test("QA: full custom-provider lifecycle against a live fake upstream", async () => { + // --- fake OpenAI-compatible upstream --- + const seen: Array<{ path: string; auth: string | undefined; model?: string }> = []; + const upstream = createServer((req, res) => { + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => { + const record = { path: req.url ?? "", auth: req.headers.authorization as string | undefined } as (typeof seen)[0]; + if (req.url?.endsWith("/models")) { + seen.push(record); + if (record.auth !== "Bearer sk-qa-good") { + res.writeHead(401, { "content-type": "application/json" }); + return res.end(JSON.stringify({ error: { message: "bad key" } })); + } + res.writeHead(200, { "content-type": "application/json" }); + return res.end(JSON.stringify({ data: [{ id: "qa-chat" }] })); + } + if (req.url?.endsWith("/chat/completions")) { + record.model = (JSON.parse(body) as { model?: string }).model; + seen.push(record); + res.writeHead(200, { "content-type": "text/event-stream" }); + const chunk = (delta: object, finish: string | null) => + `data: ${JSON.stringify({ id: "cmpl-qa", object: "chat.completion.chunk", model: "qa-chat", choices: [{ index: 0, delta, finish_reason: finish }], usage: finish ? { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 } : undefined })}\n\n`; + res.write(chunk({ role: "assistant", content: "QA UPSTREAM REPLY" }, null)); + res.write(chunk({}, "stop")); + res.write("data: [DONE]\n\n"); + return res.end(); + } + seen.push(record); + res.writeHead(404); + res.end(); + }); + }); + await new Promise((r) => upstream.listen(0, "127.0.0.1", r)); + const upstreamUrl = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}/v1`; + + const built = buildApp(testConfig({ dataDir: mkdtempSync(join(tmpdir(), "qa-custom-")) })); + const server = createInsecureTestServer(built.app, { + config: built.config, + modelCredentials: built.modelCredentials, + customProviders: built.customProviders, + refreshCustomProviders: built.refreshCustomProviders, + admin: built.admin, + auditLog: built.auditLog, + harnessId: "pi", + }); + server.listen(0); + const base = `http://localhost:${(server.address() as AddressInfo).port}`; + const api = (path: string, init?: RequestInit) => fetch(`${base}${path}`, { headers: ADMIN, ...init }); + + try { + // 1. empty list + let r = await api("/v1/admin/custom-providers"); + assert.equal(r.status, 200); + assert.deepEqual(((await r.json()) as { providers: unknown[] }).providers, []); + + // 2. guardrails + r = await api("/v1/admin/custom-providers/openai", { + method: "PUT", + body: JSON.stringify({ + name: "X", + protocol: "openai", + baseUrl: upstreamUrl, + apiKey: "sk-qa-good", + models: [{ id: "m" }], + }), + }); + assert.equal(r.status, 400, "reserved slug refused"); + r = await api("/v1/admin/custom-providers/qa", { + method: "PUT", + body: JSON.stringify({ + name: "QA", + protocol: "openai", + baseUrl: "ftp://nope", + apiKey: "sk-qa-good", + models: [{ id: "m" }], + }), + }); + assert.equal(r.status, 400, "non-http url refused"); + r = await api("/v1/admin/custom-providers/qa", { + method: "PUT", + body: JSON.stringify({ name: "QA", protocol: "openai", baseUrl: upstreamUrl, apiKey: "sk-qa-good", models: [] }), + }); + assert.equal(r.status, 400, "no models refused"); + r = await api("/v1/admin/custom-providers/qa", { + method: "PUT", + body: JSON.stringify({ + name: "QA", + protocol: "openai", + baseUrl: upstreamUrl, + apiKey: "sk-wrong", + models: [{ id: "qa-chat" }], + }), + }); + assert.equal(r.status, 400, "bad key rejected by REAL upstream 401"); + assert.equal(((await r.json()) as { error: string }).error, "invalid_api_key"); + + // 3. register for real — validation hits the live upstream + r = await api("/v1/admin/custom-providers/qa", { + method: "PUT", + body: JSON.stringify({ + name: "QA Provider", + protocol: "openai", + baseUrl: upstreamUrl, + apiKey: "sk-qa-good", + models: [{ id: "qa-chat", name: "QA Chat", contextWindow: 64000, maxTokens: 4096 }], + }), + }); + assert.equal(r.status, 200); + assert.ok( + seen.some((s) => s.path.endsWith("/models") && s.auth === "Bearer sk-qa-good"), + "validation actually reached the upstream", + ); + + // 4. list: keyConfigured true, key NEVER present anywhere in the payload + r = await api("/v1/admin/custom-providers"); + const listing = JSON.stringify(await r.json()); + assert.ok(listing.includes('"hasKey":true')); + assert.ok(!listing.includes("sk-qa-good"), "key never readable"); + + // 5. model resolves like a built-in and is catalog-visible + const model = resolveModel("qa-chat"); + assert.ok(model, "custom model resolves"); + assert.equal(model!.provider, "qa"); + assert.equal((model as { baseUrl?: string }).baseUrl, upstreamUrl); + assert.equal(modelSupportedByHarness("qa-chat", "pi"), true); + assert.equal(modelSupportedByHarness("qa-chat", "opencode"), true); + assert.equal(modelSupportedByHarness("qa-chat", "codex"), false); + assert.equal(modelServiceable("qa-chat", { anthropic: false, openai: false, openrouter: false }), true); + + // 6. REAL model call through QM's pi path → fake upstream answers + const reply = await oneShot( + "qa", + model as unknown as Model, + { qa: "sk-qa-good" }, + "you are terse", + "say anything", + ); + assert.equal(reply, "QA UPSTREAM REPLY"); + const call = seen.find((s) => s.path.endsWith("/chat/completions")); + assert.ok(call, "completion request reached the upstream"); + assert.equal(call!.model, "qa-chat"); + assert.equal(call!.auth, "Bearer sk-qa-good", "stored key was sent to the custom endpoint"); + + // 7. edit WITHOUT key keeps the stored key + r = await api("/v1/admin/custom-providers/qa", { + method: "PUT", + body: JSON.stringify({ + name: "QA Provider v2", + protocol: "openai", + baseUrl: upstreamUrl, + models: [{ id: "qa-chat" }], + }), + }); + assert.equal(r.status, 200); + r = await api("/v1/admin/custom-providers"); + assert.ok(JSON.stringify(await r.json()).includes('"hasKey":true'), "key survives keyless edit"); + + // 8. delete: models leave the registry + r = await api("/v1/admin/custom-providers/qa", { method: "DELETE" }); + assert.equal(r.status, 200); + assert.equal(resolveModel("qa-chat"), undefined, "model gone after delete"); + r = await api("/v1/admin/custom-providers/qa", { method: "DELETE" }); + assert.equal(r.status, 404, "second delete 404s"); + + // 9. non-admin cannot touch any of it + r = await fetch(`${base}/v1/admin/custom-providers`, { headers: { "content-type": "application/json" } }); + assert.notEqual(r.status, 200, "unauthenticated read refused"); + } finally { + server.close(); + upstream.close(); + } +}); + +test("QA: anthropic-protocol custom provider serves a real turn (correct wire shape + headers)", async () => { + const seen: Array<{ path: string; apiKeyHeader?: string; version?: string; model?: string }> = []; + const upstream = createServer((req, res) => { + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => { + const record = { + path: req.url ?? "", + apiKeyHeader: req.headers["x-api-key"] as string | undefined, + version: req.headers["anthropic-version"] as string | undefined, + } as (typeof seen)[0]; + if (req.url?.endsWith("/v1/models")) { + seen.push(record); + res.writeHead(record.apiKeyHeader === "sk-ant-qa" ? 200 : 401, { "content-type": "application/json" }); + return res.end(JSON.stringify({ data: [] })); + } + if (req.url?.endsWith("/v1/messages")) { + record.model = (JSON.parse(body) as { model?: string }).model; + seen.push(record); + res.writeHead(200, { "content-type": "text/event-stream" }); + res.write( + `event: message_start\ndata: ${JSON.stringify({ type: "message_start", message: { id: "msg_qa", type: "message", role: "assistant", content: [], model: "claude-compat", stop_reason: null, usage: { input_tokens: 5, output_tokens: 0 } } })}\n\n`, + ); + res.write( + `event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } })}\n\n`, + ); + res.write( + `event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "ANTHROPIC QA REPLY" } })}\n\n`, + ); + res.write(`event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: 0 })}\n\n`); + res.write( + `event: message_delta\ndata: ${JSON.stringify({ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 3 } })}\n\n`, + ); + res.write(`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`); + return res.end(); + } + seen.push(record); + res.writeHead(404); + res.end(); + }); + }); + await new Promise((r) => upstream.listen(0, "127.0.0.1", r)); + const upstreamUrl = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}`; + + const built = buildApp(testConfig({ dataDir: mkdtempSync(join(tmpdir(), "qa-ant-")) })); + const server = createInsecureTestServer(built.app, { + config: built.config, + modelCredentials: built.modelCredentials, + customProviders: built.customProviders, + refreshCustomProviders: built.refreshCustomProviders, + admin: built.admin, + auditLog: built.auditLog, + harnessId: "pi", + }); + server.listen(0); + const base = `http://localhost:${(server.address() as AddressInfo).port}`; + try { + const r = await fetch(`${base}/v1/admin/custom-providers/antcompat`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify({ + name: "Ant Compat", + protocol: "anthropic", + baseUrl: upstreamUrl, + apiKey: "sk-ant-qa", + models: [{ id: "claude-compat", name: "Claude Compat" }], + }), + }); + assert.equal(r.status, 200, "anthropic-protocol registration validates against /v1/models with x-api-key"); + const model = resolveModel("claude-compat"); + assert.ok(model); + assert.equal((model as { api?: string }).api, "anthropic-messages"); + const reply = await oneShot("qa-ant", model as unknown as Model, { antcompat: "sk-ant-qa" }, "terse", "go"); + assert.equal(reply, "ANTHROPIC QA REPLY"); + const call = seen.find((s) => s.path.endsWith("/v1/messages")); + assert.ok(call, "messages request reached the anthropic-compatible upstream"); + assert.equal(call!.model, "claude-compat"); + assert.equal(call!.apiKeyHeader, "sk-ant-qa", "anthropic wire auth uses x-api-key"); + } finally { + server.close(); + upstream.close(); + } +}); + +test("QA: registrations survive a restart (shared durable backing + same secret)", async () => { + // In production the backing map is the Postgres artifact store (same as + // model credentials); a restart is a new store instance over the same + // rows with the same CONNECTOR_SECRET_KEY. Simulate exactly that. + const backing = createMemoryMap() as Parameters[0]["backing"]; + const secret = "restart-secret-restart-secret-restart-secret"; + const first = createCustomProviderStore({ backing, keyMaterial: secret }); + await first.upsert( + { + id: "survivor", + name: "Survivor", + protocol: "openai", + baseUrl: "https://gw.example.com/v1", + models: [{ id: "survivor-model" }], + }, + "sk-live-key", + "admin-alice@default-org", + ); + // "restart": brand-new store instance over the same backing + const second = createCustomProviderStore({ backing, keyMaterial: secret }); + const enabled = await second.enabled(); + assert.equal(enabled[0]?.id, "survivor", "spec survives the restart"); + assert.equal(await second.resolveKey("survivor"), "sk-live-key", "key decrypts after restart with the same secret"); + // and the hydration path wires it into the runtime registry + setCustomProviders(enabled); + assert.ok(resolveModel("survivor-model"), "hydrated model resolves"); + setCustomProviders([]); +}); + +test("QA: a corrupt stored key degrades that provider only — admin surface stays intact", async () => { + const backing = createMemoryMap() as Parameters[0]["backing"]; + const writer = createCustomProviderStore({ backing, keyMaterial: "first-secret-first-secret-first-secret-1" }); + await writer.upsert( + { + id: "corrupted", + name: "Corrupted", + protocol: "openai", + baseUrl: "https://gw.example.com/v1", + models: [{ id: "corrupted-model" }], + }, + "sk-will-be-unreadable", + "admin-alice@default-org", + ); + // reboot with a DIFFERENT secret: the stored key is undecryptable + const reader = createCustomProviderStore({ backing, keyMaterial: "other-secret-other-secret-other-secret-2" }); + await assert.rejects(reader.resolveKey("corrupted"), "decryption fails with the wrong secret"); + const statuses = await reader.statuses(); + assert.equal(statuses[0]?.id, "corrupted"); + assert.equal(statuses[0]?.hasKey, true, "admin surface (no secrets) unaffected"); + const enabled = await reader.enabled(); + assert.equal(enabled[0]?.id, "corrupted", "spec listing unaffected — only the key is lost"); +}); diff --git a/test/custom-provider-route.test.ts b/test/custom-provider-route.test.ts new file mode 100644 index 00000000..910bdd9e --- /dev/null +++ b/test/custom-provider-route.test.ts @@ -0,0 +1,149 @@ +import "./support/auto-fake-sprites.ts"; + +import assert from "node:assert/strict"; +import type { AddressInfo } from "node:net"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test, afterEach } from "node:test"; +import { createInsecureTestServer } from "../src/api/server.ts"; +import { buildApp, type BuiltApp } from "../src/wiring.ts"; +import { testConfig } from "./support/test-config.ts"; +import { resolveModel } from "../src/model/pi-models.ts"; +import { setCustomProviders } from "../src/model/custom-providers.ts"; + +const ADMIN = { "content-type": "application/json", "x-admin-actor": "admin-alice@default-org" }; +const USER = { "content-type": "application/json", "x-admin-actor": "bob@default-org" }; + +afterEach(() => setCustomProviders([])); + +function start(modelCredentialFetch: typeof fetch = async () => new Response(null, { status: 200 })): { + base: string; + built: BuiltApp; + close: () => Promise; +} { + const built = buildApp(testConfig({ dataDir: mkdtempSync(join(tmpdir(), "custom-provider-route-")) }), { + modelCredentialFetch, + }); + const server = createInsecureTestServer(built.app, { + config: built.config, + modelCredentials: built.modelCredentials, + customProviders: built.customProviders, + refreshCustomProviders: built.refreshCustomProviders, + modelCredentialFetch, + harnessId: "pi", + providerKeys: { anthropic: true, openai: false, openrouter: false }, + admin: built.admin, + auditLog: built.auditLog, + }); + server.listen(0); + return { + base: `http://localhost:${(server.address() as AddressInfo).port}`, + built, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + +const BODY = { + name: "Acme Gateway", + protocol: "openai", + baseUrl: "https://llm.acme.internal/v1", + models: [{ id: "acme-large", name: "Acme Large" }], + apiKey: "sk-acme-secret", +}; + +test("custom provider lifecycle: register, list, resolve, delete — admin only, no key leakage", async () => { + const validated: string[] = []; + const srv = start(async (input) => { + validated.push(String(input)); + return new Response(null, { status: 200 }); + }); + try { + // Register (validates against the endpoint's /models). + const put = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify(BODY), + }); + assert.equal(put.status, 200); + assert.ok(validated.some((u) => u === "https://llm.acme.internal/v1/models")); + const putBody = (await put.json()) as { status: { hasKey: boolean } }; + assert.equal(putBody.status.hasKey, true); + assert.equal(JSON.stringify(putBody).includes("sk-acme-secret"), false); + + // The runtime registry serves the model immediately. + assert.equal(String(resolveModel("acme-large")?.provider), "acme-gateway"); + + // List never leaks the key. + const list = await fetch(`${srv.base}/v1/admin/custom-providers`, { headers: ADMIN }); + assert.equal(list.status, 200); + const listBody = await list.text(); + assert.equal(listBody.includes("sk-acme-secret"), false); + assert.ok(listBody.includes("acme-gateway")); + + // Non-admin gets refused. + const denied = await fetch(`${srv.base}/v1/admin/custom-providers`, { headers: USER }); + assert.notEqual(denied.status, 200); + + // Delete disables and clears the registry. + const del = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway`, { + method: "DELETE", + headers: ADMIN, + }); + assert.equal(del.status, 200); + assert.equal(resolveModel("acme-large"), undefined); + } finally { + await srv.close(); + } +}); + +test("a rejected key blocks registration unless validate:false", async () => { + const srv = start(async () => new Response(null, { status: 401 })); + try { + const put = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify(BODY), + }); + assert.equal(put.status, 400); + assert.equal(((await put.json()) as { error: string }).error, "invalid_api_key"); + + const skip = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify({ ...BODY, validate: false }), + }); + assert.equal(skip.status, 200); + } finally { + await srv.close(); + } +}); + +test("bad specs are refused with a reason", async () => { + const srv = start(); + try { + for (const [patch, reason] of [ + [{ models: [] }, /at least one model/], + [{ protocol: "grpc" }, /protocol/], + [{ baseUrl: "https://x?y=1" }, /query/], + ] as const) { + const res = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify({ ...BODY, ...patch, validate: false }), + }); + assert.equal(res.status, 400); + assert.match(((await res.json()) as { message: string }).message, reason); + } + // Reserved slug via the path. + const reserved = await fetch(`${srv.base}/v1/admin/custom-providers/openai`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify({ ...BODY, validate: false }), + }); + assert.equal(reserved.status, 400); + assert.match(((await reserved.json()) as { message: string }).message, /reserved/); + } finally { + await srv.close(); + } +}); diff --git a/test/custom-providers.test.ts b/test/custom-providers.test.ts new file mode 100644 index 00000000..6621b64d --- /dev/null +++ b/test/custom-providers.test.ts @@ -0,0 +1,192 @@ +import { test, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { + setCustomProviders, + resolveCustomModel, + isCustomModelId, + customModelCatalog, + validateCustomProviderSpec, +} from "../src/model/custom-providers.ts"; +import { builtInModelCatalog } from "../src/model/model-catalog.ts"; +import { createCustomProviderStore } from "../src/model/custom-provider-store.ts"; +import { modelSupportedByHarness, modelServiceable, resolveModel } from "../src/model/pi-models.ts"; +import { createMemoryMap } from "../src/persistence/durable-map.ts"; +import type { StoredCustomProvider } from "../src/model/custom-provider-store.ts"; + +afterEach(() => setCustomProviders([])); + +const GATEWAY = { + id: "acme-gateway", + name: "Acme Gateway", + protocol: "openai" as const, + baseUrl: "https://llm.acme.internal/v1", + models: [{ id: "acme-large", name: "Acme Large", contextWindow: 200_000, maxTokens: 16_000, input: 2, output: 8 }], +}; + +test("a registered custom model resolves with the provider's protocol and base URL", () => { + setCustomProviders([GATEWAY]); + const model = resolveCustomModel("acme-large"); + assert.ok(model); + assert.equal(model.provider, "acme-gateway"); + assert.equal(model.api, "openai-completions"); + assert.equal(model.baseUrl, "https://llm.acme.internal/v1"); + assert.equal(model.contextWindow, 200_000); + assert.equal(model.cost.input, 2); +}); + +test("anthropic-protocol providers produce anthropic-messages models with defaults", () => { + setCustomProviders([ + { + id: "eu-anthropic", + name: "EU Anthropic-compatible", + protocol: "anthropic", + baseUrl: "https://eu.example.com", + models: [{ id: "eu-claude" }], + }, + ]); + const model = resolveCustomModel("eu-claude"); + assert.ok(model); + assert.equal(model.api, "anthropic-messages"); + assert.equal(model.contextWindow, 128_000); + assert.equal(model.cost.input, 0); +}); + +test("resolveModel falls back to custom models; built-ins shadow custom ids", () => { + setCustomProviders([{ ...GATEWAY, models: [{ id: "acme-large" }, { id: "claude-opus-5", name: "impostor" }] }]); + assert.equal(resolveModel("acme-large")?.provider, "acme-gateway"); + // The built-in claude-opus-5 must win over a custom model claiming its id. + assert.equal(String(resolveModel("claude-opus-5")?.provider), "anthropic"); +}); + +test("custom models are gated to pi and mock harnesses", () => { + setCustomProviders([GATEWAY]); + assert.equal(modelSupportedByHarness("acme-large", "pi"), true); + assert.equal(modelSupportedByHarness("acme-large", "mock"), true); + assert.equal(modelSupportedByHarness("acme-large", "claude"), false); + assert.equal(modelSupportedByHarness("acme-large", "codex"), false); + assert.equal(modelSupportedByHarness("acme-large", "opencode"), true); +}); + +test("a registered custom model is serviceable regardless of built-in key availability", () => { + setCustomProviders([GATEWAY]); + assert.equal(modelServiceable("acme-large", { anthropic: false, openai: false, openrouter: false }), true); +}); + +test("catalog lists custom models; clearing the registry removes them", () => { + setCustomProviders([GATEWAY]); + assert.deepEqual(customModelCatalog(), [{ id: "acme-large", name: "Acme Large", provider: "acme-gateway" }]); + setCustomProviders([]); + assert.equal(isCustomModelId("acme-large"), false); + assert.equal(resolveModel("acme-large"), undefined); +}); + +test("spec validation rejects reserved ids, bad slugs, bad URLs, and empty model lists", () => { + assert.throws(() => validateCustomProviderSpec({ ...GATEWAY, id: "openai" }), /reserved/); + assert.throws(() => validateCustomProviderSpec({ ...GATEWAY, id: "Not A Slug" }), /slug/); + assert.throws(() => validateCustomProviderSpec({ ...GATEWAY, baseUrl: "ftp://x" }), /http/); + assert.throws(() => validateCustomProviderSpec({ ...GATEWAY, baseUrl: "https://x?y=1" }), /query/); + assert.throws(() => validateCustomProviderSpec({ ...GATEWAY, models: [] }), /at least one model/); + assert.throws(() => validateCustomProviderSpec({ ...GATEWAY, models: [{ id: "a" }, { id: "a" }] }), /duplicate/); +}); + +test("store round-trip: upsert encrypts the key, statuses never leak it, delete disables", async () => { + const backing = createMemoryMap(); + const store = createCustomProviderStore({ backing, keyMaterial: "test-key-material" }); + + await store.upsert(GATEWAY, "sk-secret-123", "admin@example.com"); + const statuses = await store.statuses(); + assert.equal(statuses.length, 1); + assert.equal(statuses[0]!.hasKey, true); + assert.equal(JSON.stringify(statuses).includes("sk-secret-123"), false); + + const raw = await backing.get("acme-gateway"); + assert.ok(raw?.apiKeyEnc); + assert.equal(raw!.apiKeyEnc!.includes("sk-secret-123"), false); + + assert.equal(await store.resolveKey("acme-gateway"), "sk-secret-123"); + assert.deepEqual(await store.enabled(), [GATEWAY]); + + // Upsert without a key keeps the existing one. + await store.upsert({ ...GATEWAY, name: "Renamed" }, undefined, "admin@example.com"); + assert.equal(await store.resolveKey("acme-gateway"), "sk-secret-123"); + + assert.equal(await store.delete("acme-gateway", "admin@example.com"), true); + assert.equal(await store.resolveKey("acme-gateway"), null); + assert.deepEqual(await store.enabled(), []); + assert.equal((await store.statuses())[0]!.disabled, true); + assert.equal(await store.delete("never-existed", "admin@example.com"), false); +}); + +test("store validates specs on upsert", async () => { + const store = createCustomProviderStore({ + backing: createMemoryMap(), + keyMaterial: "k", + }); + await assert.rejects(store.upsert({ ...GATEWAY, id: "anthropic" }, "k", "a@b.c"), /reserved/); +}); + +test("registered models surface in the catalog and vanish on unregister", () => { + setCustomProviders([ + { + id: "deepseek", + name: "DeepSeek", + protocol: "openai", + baseUrl: "https://api.deepseek.com/v1", + models: [{ id: "deepseek-chat", name: "DeepSeek Chat" }], + }, + ]); + const catalog = builtInModelCatalog(); + const entry = catalog.find((m) => m.id === "deepseek-chat"); + assert.ok(entry, "custom model appears in the catalog"); + assert.equal(entry!.provider, "deepseek"); + setCustomProviders([]); + assert.ok(!builtInModelCatalog().some((m) => m.id === "deepseek-chat")); +}); + +test("opencode modelRef routes slashed custom model ids to the registered provider, not a phantom slash-prefix", async () => { + const { modelRef } = await import("../src/harness/opencode-harness.ts"); + setCustomProviders([ + { + id: "litellm", + name: "LiteLLM", + protocol: "openai", + baseUrl: "https://litellm.example.com/v1", + models: [{ id: "bedrock/claude-opus-5" }], + }, + ]); + try { + assert.deepEqual(modelRef("bedrock/claude-opus-5"), { providerID: "litellm", modelID: "bedrock/claude-opus-5" }); + // built-in slash convention untouched + assert.deepEqual(modelRef("openrouter/auto"), { providerID: "openrouter", modelID: "auto" }); + } finally { + setCustomProviders([]); + } +}); + +test("catalog cache invalidates immediately when the custom registry changes", async () => { + const { selectableModelCatalog } = await import("../src/model/model-catalog.ts"); + const fetcher: typeof fetch = async () => new Response(JSON.stringify({ data: [] }), { status: 200 }); + setCustomProviders([]); + const before = await selectableModelCatalog(fetcher); + assert.ok(!before.some((m) => m.id === "fresh-model")); + setCustomProviders([ + { + id: "freshco", + name: "FreshCo", + protocol: "openai", + baseUrl: "https://fresh.example.com/v1", + models: [{ id: "fresh-model" }], + }, + ]); + try { + const after = await selectableModelCatalog(fetcher); + assert.ok( + after.some((m) => m.id === "fresh-model"), + "new registration visible without waiting out the TTL", + ); + } finally { + setCustomProviders([]); + } + const cleared = await selectableModelCatalog(fetcher); + assert.ok(!cleared.some((m) => m.id === "fresh-model"), "removal visible immediately too"); +}); diff --git a/test/deploy-edit-widget.test.ts b/test/deploy-app-shell.test.ts similarity index 73% rename from test/deploy-edit-widget.test.ts rename to test/deploy-app-shell.test.ts index 6ebbd35a..b5c2d863 100644 --- a/test/deploy-edit-widget.test.ts +++ b/test/deploy-app-shell.test.ts @@ -33,7 +33,7 @@ function appServingUpstream(upstreamPort: number) { }, auditLog, acl, - deployDir: mkdtempSync(join(tmpdir(), "edit-widget-")), + deployDir: mkdtempSync(join(tmpdir(), "app-shell-")), }); return createApp({ deploy, @@ -109,7 +109,7 @@ const viewerCookie = () => `portal_session=${mintPortalSession("U-viewer")}`; const ownerToken = (sub: string, expInMs = 60_000) => mintDeployOwnerToken(GATE_SECRET, { slug: "mysite", sub, exp: Date.now() + expInMs }); -test("edit widget: a valid owner link becomes a host-only cookie and turns on HTML injection", async () => { +test("app shell: a valid owner link becomes a host-only cookie and turns on the shell", async () => { const f = await widgetFixture(); try { const token = await ownerToken("U1"); @@ -120,40 +120,81 @@ test("edit widget: a valid owner link becomes a host-only cookie and turns on HT assert.match(setCookie, /HttpOnly/, "the owner cookie is HttpOnly"); assert.equal(swallow.headers.location, "/", "the redirect drops the token from the URL"); - const page = await httpGet(f.port, "/", { Host: HOST, Cookie: `dpl_owner=${token}` }); + const page = await httpGet(f.port, "/", { + Host: HOST, + Cookie: `dpl_owner=${token}`, + "Sec-Fetch-Dest": "document", + }); assert.equal(page.status, 200); - assert.match(page.body, /APP<\/body><\/html>/, "the app's own HTML is intact"); - assert.match(page.body, /