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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 28 additions & 28 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
"@earendil-works/pi-ai": "0.82.0",
"@earendil-works/pi-coding-agent": "https://github.com/yc-software/pi/releases/download/qm-pi-coding-agent-0.82.0-security.3/earendil-works-pi-coding-agent-0.82.0-qm-security.3.tgz",
"@fly/sprites": "0.0.1",
"@openai/codex": "0.144.5",
"@openai/codex": "0.153.4",
"@opencode-ai/plugin": "1.17.18",
"@opencode-ai/sdk": "1.17.18",
"@slack/bolt": "^4.7.3",
Expand Down
3 changes: 1 addition & 2 deletions plugins/web-ui/src/composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,7 +456,6 @@ export function createComposerSurface(ctx: ConvCtx): ComposerSurface {
}

const compact = Boolean(ctx.pane) || isPhone();
const showRuntimeControls = !appState.me?.individualModelAuth;
const runtimeControls = compact
? settingsControl(agent, selectedModel, inputBlocked)
: html`
Expand Down Expand Up @@ -653,7 +652,7 @@ export function createComposerSurface(ctx: ConvCtx): ComposerSurface {
`
}
</div>
<div class="composer-right">${showRuntimeControls ? runtimeControls : nothing} ${sendControls(agent)}</div>
<div class="composer-right">${runtimeControls} ${sendControls(agent)}</div>
</div>
${composerNotice}
</form>
Expand Down
2 changes: 1 addition & 1 deletion plugins/web-ui/test/composer-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ test("composer-right keeps its control order: make default, use org default, mod
composer.indexOf("return html`", composer.indexOf("const runtimeControls =")),
);
const rendered = composer.slice(composer.indexOf('class="composer-right"'));
assert.match(rendered, /showRuntimeControls \? runtimeControls : nothing/);
assert.match(rendered, /\$\{runtimeControls\}/);
const right = runtime + rendered;
const makeDefault = right.indexOf("Make default");
const orgDefault = right.indexOf("Use org default");
Expand Down
27 changes: 24 additions & 3 deletions src/api/app-turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { swallow, swallowAs } from "../util/errors.ts";
import { sleep } from "../util/async.ts";
import { GENERIC_FAILURE_CLAUSE } from "../../plugins/chassis/src/failure-copy.ts";

import { runtimeConfigBody } from "./runtime-config.ts";
import type { App, AppDeps } from "./app-types.ts";
import { STALE_LEASE_GRACE_MS } from "./app-types.ts";

Expand Down Expand Up @@ -145,6 +146,8 @@ export function createTurnMethods(
}

const individualAuth = !!deps.userModelCredentials && (await deps.config.getIndividualModelAuthDurable());
let selectedHarness = individualAuth ? undefined : req.harness;
let selectedModel = individualAuth ? undefined : req.model;
if (req.surface === "web") {
const threadRef = req.conversation.threadRef;
const existing = await deps.sessions.getByThread(threadRef);
Expand All @@ -163,7 +166,25 @@ export function createTurnMethods(
harnessId: fallbackHarness,
modelId: defaultModelForHarness(fallbackHarness),
};
if (!individualAuth) {
if (individualAuth) {
const available = await runtimeConfigBody(
{ deps: { ...deps, baseModelDefault: runtimeFallback.modelId } },
targetScope,
actor.id,
);
selectedHarness = req.harness ?? available.effective.harnessId;
selectedModel = req.model ?? available.effective.modelId;
if (!available.modelsByHarness[selectedHarness]?.includes(selectedModel))
return {
status: "refused",
reason: "that model isn't available for your connected AI account or isn't approved",
};
const invalidModelOption = validateWebTurnModelOptions(
{ ...req, model: selectedModel },
available.modelsByHarness[selectedHarness] ?? [],
);
if (invalidModelOption) return { status: "refused", reason: invalidModelOption };
} else {
const configuredKeys = deps.providerKeys ??
deps.modelProviders ?? { anthropic: false, openai: false, openrouter: false };
const managedKeys = deps.modelCredentials ? await deps.modelCredentials.availability() : configuredKeys;
Expand Down Expand Up @@ -268,8 +289,8 @@ export function createTurnMethods(
...(req.detectOpener ? { detectOpener: req.detectOpener } : {}),
...(req.attachments?.length ? { attachments: req.attachments } : {}),
...(req.inboundNotes?.length ? { inboundNotes: req.inboundNotes } : {}),
...(!individualAuth && req.harness ? { harness: req.harness } : {}),
...(!individualAuth && req.model ? { model: req.model } : {}),
...(selectedHarness ? { harness: selectedHarness } : {}),
...(selectedModel ? { model: selectedModel } : {}),
...turnModelOptions(req),
...(req.readOnly ? { readOnly: true } : {}),
...(req.skipMemory ? { skipMemory: true } : {}),
Expand Down
9 changes: 7 additions & 2 deletions src/api/routes/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1125,7 +1125,7 @@ async function getRuntimeConfig(ctx: ApiCtx): Promise<void> {
const target = await runtimeTarget(ctx);
if (!target) return sendJson(ctx.res, 403, { error: "forbidden" });
await ctx.deps.refreshModels?.();
return sendJson(ctx.res, 200, await runtimeConfigBody(ctx, target.scope));
return sendJson(ctx.res, 200, await runtimeConfigBody(ctx, target.scope, target.actorId));
}

async function putRuntimeConfig(ctx: ApiCtx): Promise<void> {
Expand Down Expand Up @@ -1163,6 +1163,11 @@ async function putRuntimeConfig(ctx: ApiCtx): Promise<void> {
if (typeof modelId !== "string" || !modelSupportedByHarness(modelId, harnessId))
return sendJson(ctx.res, 400, { error: "model_not_supported" });
if (!(await webuiModelEnabled(ctx, modelId))) return sendJson(ctx.res, 400, { error: "model_not_enabled" });
if (ctx.deps.userModelCredentials && (await config.getIndividualModelAuthDurable())) {
const available = await runtimeConfigBody(ctx, target.scope, target.actorId);
if (!available.modelsByHarness[harnessId]?.includes(modelId))
return sendJson(ctx.res, 400, { error: "model_not_available_for_account" });
}
const effortLevel = ctx.body.effortLevel ?? "auto";
if (typeof effortLevel !== "string" || !(THINKING_LEVELS as readonly string[]).includes(effortLevel))
return sendJson(ctx.res, 400, { error: "effort_not_supported" });
Expand All @@ -1177,7 +1182,7 @@ async function putRuntimeConfig(ctx: ApiCtx): Promise<void> {
resource: "runtime-config",
scopeLabel: target.scope,
});
return sendJson(ctx.res, 200, await runtimeConfigBody(ctx, target.scope));
return sendJson(ctx.res, 200, await runtimeConfigBody(ctx, target.scope, target.actorId));
}

async function getChannelHeaderPin(ctx: ApiCtx): Promise<void> {
Expand Down
69 changes: 50 additions & 19 deletions src/api/runtime-config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { ServerDeps } from "./deps.ts";
import type { ScopedConfigStore } from "../resolution/config-store.ts";
import type { ModelCredentialStore } from "../model/model-credential-store.ts";
import type { UserModelCredentialStore } from "../model/user-model-credential-store.ts";
import type { ScopeId } from "../types.ts";
import { orgScope } from "./routes/shared.ts";
import { orgScope } from "../config.ts";
import {
defaultModelForHarness,
isHarnessId,
Expand All @@ -15,30 +17,32 @@ import {
thinkingLevelsForHarness,
harnessSupportsFastMode,
type HarnessId,
type ModelProviderAvailability,
} from "../model/pi-models.ts";
import { builtInModelCatalog, selectableCatalogForHarness, selectableModelCatalog } from "../model/model-catalog.ts";
import { resolveIndividualAuthRouting } from "../core/individual-auth-routing.ts";
import type { RuntimeChoice } from "../harness/harness.ts";

export type RuntimeDeps = Pick<
ServerDeps,
| "config"
| "harnessId"
| "baseModelDefault"
| "providerKeys"
| "modelCredentials"
| "modelCredentialFetch"
| "refreshModels"
>;
export interface RuntimeDeps {
config?: ScopedConfigStore;
harnessId?: string;
baseModelDefault?: string;
providerKeys?: ModelProviderAvailability;
modelCredentials?: ModelCredentialStore;
modelCredentialFetch?: typeof fetch;
refreshModels?: () => Promise<void>;
userModelCredentials?: UserModelCredentialStore;
}

export function runtimeFallback(ctx: { deps: RuntimeDeps }): { harnessId: HarnessId; modelId: string } {
const harnessId = isHarnessId(ctx.deps.harnessId) ? ctx.deps.harnessId : "pi";
return { harnessId, modelId: ctx.deps.baseModelDefault ?? defaultModelForHarness(harnessId) };
}

export async function runtimeConfigBody(ctx: { deps: RuntimeDeps }, scope: ScopeId) {
export async function runtimeConfigBody(ctx: { deps: RuntimeDeps }, scope: ScopeId, principalId?: string) {
const config = ctx.deps.config!;
const fallback = runtimeFallback(ctx);
const org = orgScope(ctx.deps);
const org = orgScope();
const approvedHarnesses = ((await config.getApprovedHarnessesDurable()) ?? [fallback.harnessId]).filter(isHarnessId);
const configuredKeys = ctx.deps.providerKeys ?? ALL_PROVIDERS_AVAILABLE;
const managedKeys = ctx.deps.modelCredentials ? await ctx.deps.modelCredentials.availability() : configuredKeys;
Expand Down Expand Up @@ -87,7 +91,23 @@ export async function runtimeConfigBody(ctx: { deps: RuntimeDeps }, scope: Scope
} else if (legacyModel) {
scopeOverride = { harnessId: fallback.harnessId, modelId: legacyModel, orgRevision: 0 };
}
const effective = scopeOverride ?? orgDefault;
const individualAuth = Boolean(
principalId && ctx.deps.userModelCredentials && (await config.getIndividualModelAuthDurable()),
);
const [anthCred, oaiCred] = individualAuth
? await Promise.all([
ctx.deps.userModelCredentials!.get(principalId!, "anthropic"),
ctx.deps.userModelCredentials!.get(principalId!, "openai"),
])
: [null, null];
let effective = scopeOverride ?? orgDefault;
if (individualAuth) {
const requested = scopeOverride ?? orgDefault;
const routing =
resolveIndividualAuthRouting(anthCred, oaiCred, requested.modelId, requested.harnessId) ??
(scopeOverride ? null : resolveIndividualAuthRouting(anthCred, oaiCred, undefined, fallback.harnessId));
if (routing?.model) effective = { ...requested, harnessId: routing.harness, modelId: routing.model };
}
const selected = [orgDefault, scopeOverride, effective].filter((choice) => choice !== null);
const allowlist = await config.getWebuiModelsDurable(org);
const modelsByHarness = Object.fromEntries(
Expand All @@ -100,14 +120,23 @@ export async function runtimeConfigBody(ctx: { deps: RuntimeDeps }, scope: Scope
.map((model) => model.id);
for (const choice of selected) {
if (
(!individualAuth || allowlist == null) &&
allowlist?.length !== 0 &&
choice.harnessId === harnessId &&
modelSupportedByHarness(choice.modelId, harnessId) &&
!ids.includes(choice.modelId)
)
ids.push(choice.modelId);
}
return [harnessId, serviceableModelIds(ids, providersFor(harnessId))];
return [
harnessId,
individualAuth
? ids.filter((modelId) => {
const routing = resolveIndividualAuthRouting(anthCred, oaiCred, modelId, harnessId);
return routing?.harness === harnessId && routing.model === modelId;
})
: serviceableModelIds(ids, providersFor(harnessId)),
];
}),
);
const advertisedModelIds = new Set(Object.values(modelsByHarness).flat());
Expand All @@ -119,7 +148,9 @@ export async function runtimeConfigBody(ctx: { deps: RuntimeDeps }, scope: Scope
);
return {
scopeId: scope,
approvedHarnesses,
approvedHarnesses: individualAuth
? approvedHarnesses.filter((id) => modelsByHarness[id]?.length)
: approvedHarnesses,
modelsByHarness,
modelCatalog,
orgDefault,
Expand Down Expand Up @@ -155,10 +186,10 @@ export function validateRuntimeChoice(choice: RuntimeChoice): string | null {
export async function webuiModelEnabled(ctx: { deps: RuntimeDeps }, modelId: string): Promise<boolean> {
modelId = modelId.replace(/^codex\//, "");
const config = ctx.deps.config!;
const picker = await config.getWebuiModelsDurable(orgScope(ctx.deps));
const picker = await config.getWebuiModelsDurable(orgScope());
if (picker == null || picker.includes(modelId)) return true;
if (picker.length === 0) return false;
const org = orgScope(ctx.deps);
const org = orgScope();
const stored = await config.getRuntimeSelectionDurable(org);
const orgModel = stored?.modelId ?? (await config.getBaseModelOwnDurable(org)) ?? runtimeFallback(ctx).modelId;
return modelId === orgModel;
Expand Down
1 change: 1 addition & 0 deletions src/core/individual-auth-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export function resolveIndividualAuthRouting(
const pick = ((): { provider: "anthropic" | "openai"; cred: UserModelCredential } | null => {
if (requestedProvider === "anthropic" && anthCred) return { provider: "anthropic", cred: anthCred };
if (requestedProvider === "openai" && oaiCred) return { provider: "openai", cred: oaiCred };
if (requestedProvider) return null;
if (anthCred) return { provider: "anthropic", cred: anthCred };
if (oaiCred) return { provider: "openai", cred: oaiCred };
return null;
Expand Down
Loading