Onboarding: emoji-first flow with shape/color selection, user preferences, language setup (9 locales), and bot creation with avatarValue persistence. executor.ts: split 4928→4159 lines into 8 modules (completion, computer, effects, model, utils, tools, attachments, types). pi-runtime.ts: split 1726→561 lines into 2 modules (model-config, tool-utils). E2E: new onboarding-emoji-first tests + updated completeOnboarding helper.
208 lines
6.9 KiB
TypeScript
208 lines
6.9 KiB
TypeScript
import type { AgentModelOAuthCredential } from "@rakazo/adapter-kit";
|
|
import type { AgentRunRequest } from "@rakazo/adapter-kit";
|
|
import { OPENAI_COMPATIBLE_PROVIDER_ID } from "@rakazo/contracts";
|
|
import { resolveDeploymentModel } from "./deployment-model.js";
|
|
import { modelIdSupportsImages } from "./model-vision.js";
|
|
import { toOAuthCredential } from "./pi-credentials.js";
|
|
import {
|
|
parseModelSecret,
|
|
resolveModelAuth,
|
|
secretValuesToRedact,
|
|
serializeModelSecret,
|
|
} from "./pi-oauth.js";
|
|
|
|
import type { ExecutorDeps } from "./executor-types.js";
|
|
|
|
const modelCredentialLocks = new Map<string, Promise<void>>();
|
|
|
|
/**
|
|
* Return a deployment API key when the winning provider matches the
|
|
* configured deployment model provider. A provider named by deployment
|
|
* settings or a bot override gets no key rather than another vendor's.
|
|
*/
|
|
export function deploymentKeyFor(
|
|
deps: ExecutorDeps,
|
|
provider: string,
|
|
): string | undefined {
|
|
if (!deps.deploymentModelKey) return undefined;
|
|
return provider === resolveDeploymentModel().provider
|
|
? deps.deploymentModelKey
|
|
: undefined;
|
|
}
|
|
|
|
/**
|
|
* Resolve model credentials for a given provider/model pair.
|
|
*
|
|
* When a credential row exists, fetch the secret from the database,
|
|
* apply any required OAuth flow, and return the resolved API key plus
|
|
* metadata (base URL, reasoning flags, image support, etc.).
|
|
*
|
|
* When no credential exists, fall back to the deployment key for the
|
|
* winning provider.
|
|
*/
|
|
export async function resolveModelKey(
|
|
deps: ExecutorDeps,
|
|
userId: string,
|
|
spaceId: string,
|
|
credential: {
|
|
secretId: string;
|
|
provider: string;
|
|
defaultModel?: string | null;
|
|
supportsImages?: boolean;
|
|
} | null,
|
|
provider: string,
|
|
modelId: string,
|
|
registerSecrets?: (values: string[]) => void,
|
|
): Promise<{
|
|
apiKey?: string;
|
|
baseUrl?: string;
|
|
reasoning?: boolean;
|
|
maxTokens?: number;
|
|
contextWindow?: number;
|
|
thinkingLevel?: AgentRunRequest["model"]["thinkingLevel"];
|
|
acceptsImages?: boolean;
|
|
maxImagesPerPrompt?: number;
|
|
oauth?: AgentModelOAuthCredential;
|
|
persistOAuth?: (credential: AgentModelOAuthCredential) => Promise<void>;
|
|
redact: string[];
|
|
}> {
|
|
if (credential) {
|
|
return withModelCredentialLock(credential.secretId, async () => {
|
|
const row = await deps.prisma.secret.findFirst({
|
|
where: { id: credential.secretId, userId, spaceId: null },
|
|
});
|
|
if (!row)
|
|
return { apiKey: deploymentKeyFor(deps, provider), redact: [] };
|
|
const plaintext = deps.secretStore.load(row.ciphertext, row.id);
|
|
registerSecrets?.(secretValuesToRedact(parseModelSecret(plaintext)));
|
|
const persist = async (next: string) => {
|
|
const stored = await deps.secretStore.put(
|
|
next,
|
|
{
|
|
operationId: "cred",
|
|
traceId: "cred-refresh",
|
|
spaceId,
|
|
userId,
|
|
signal: new AbortController().signal,
|
|
},
|
|
row.id,
|
|
);
|
|
await deps.prisma.secret.update({
|
|
where: { id: row.id },
|
|
data: { ciphertext: stored.ciphertext },
|
|
});
|
|
};
|
|
const resolved = await resolveModelAuth(plaintext, credential.provider, {
|
|
persist,
|
|
});
|
|
const oauth =
|
|
resolved.secret.kind === "oauth"
|
|
? resolved.secret.credential
|
|
: undefined;
|
|
const baseUrl =
|
|
resolved.secret.kind === "openai_compatible"
|
|
? resolved.secret.baseUrl
|
|
: undefined;
|
|
const acceptsImages =
|
|
credential.provider === OPENAI_COMPATIBLE_PROVIDER_ID &&
|
|
resolved.secret.kind === "openai_compatible" &&
|
|
(modelIdSupportsImages(resolved.secret.visionModelIds, modelId) ||
|
|
// Legacy secrets have no per-model list, so keep their existing
|
|
// capability scoped to the model saved in the space preference.
|
|
(resolved.secret.visionModelIds === undefined &&
|
|
credential.supportsImages === true &&
|
|
credential.defaultModel?.trim() === modelId.trim()));
|
|
return {
|
|
apiKey: resolved.apiKey,
|
|
baseUrl,
|
|
reasoning:
|
|
resolved.secret.kind === "openai_compatible"
|
|
? resolved.secret.reasoning
|
|
: undefined,
|
|
maxTokens:
|
|
resolved.secret.kind === "openai_compatible"
|
|
? resolved.secret.maxTokens
|
|
: undefined,
|
|
contextWindow:
|
|
resolved.secret.kind === "openai_compatible"
|
|
? resolved.secret.contextWindow
|
|
: undefined,
|
|
thinkingLevel:
|
|
resolved.secret.kind === "openai_compatible"
|
|
? resolved.secret.thinkingLevel
|
|
: undefined,
|
|
acceptsImages,
|
|
maxImagesPerPrompt:
|
|
resolved.secret.kind === "openai_compatible"
|
|
? resolved.secret.maxImagesPerPrompt
|
|
: undefined,
|
|
oauth,
|
|
persistOAuth: oauth
|
|
? async (next) => {
|
|
await withModelCredentialLock(credential.secretId, async () => {
|
|
const currentRow = await deps.prisma.secret.findFirst({
|
|
where: { id: credential.secretId, userId, spaceId: null },
|
|
});
|
|
if (!currentRow) return;
|
|
const current = parseModelSecret(
|
|
deps.secretStore.load(
|
|
currentRow.ciphertext,
|
|
currentRow.id,
|
|
),
|
|
);
|
|
if (current.kind === "oauth") {
|
|
const stored = current.credential;
|
|
if (stored.expires > next.expires) return;
|
|
if (
|
|
stored.access === next.access &&
|
|
stored.refresh === next.refresh &&
|
|
stored.expires === next.expires
|
|
) {
|
|
return;
|
|
}
|
|
}
|
|
await persist(
|
|
serializeModelSecret({
|
|
kind: "oauth",
|
|
credential: toOAuthCredential(next),
|
|
}),
|
|
);
|
|
});
|
|
}
|
|
: undefined,
|
|
redact: [...secretValuesToRedact(resolved.secret), resolved.apiKey].filter(
|
|
(value): value is string => Boolean(value),
|
|
),
|
|
};
|
|
});
|
|
}
|
|
return { apiKey: deploymentKeyFor(deps, provider), redact: [] };
|
|
}
|
|
|
|
/**
|
|
* Serialize access to credential operations for a single secret key.
|
|
* Prevents concurrent reads/writes from producing torn state.
|
|
*/
|
|
export async function withModelCredentialLock<T>(
|
|
key: string,
|
|
fn: () => Promise<T>,
|
|
): Promise<T> {
|
|
const previous = modelCredentialLocks.get(key) ?? Promise.resolve();
|
|
let release!: () => void;
|
|
const current = previous.then(
|
|
() =>
|
|
new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
}),
|
|
);
|
|
modelCredentialLocks.set(key, current);
|
|
await previous;
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
release();
|
|
if (modelCredentialLocks.get(key) === current)
|
|
modelCredentialLocks.delete(key);
|
|
}
|
|
}
|