blob: 43309621222dd1606d35570657dd8988e9acc284 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { bundledModelMetadata, installRefreshedModelMetadata } from '@maka/core/model-metadata';
import { fetchModelsDevProjection } from '@maka/core/models-dev-refresh';
import { redactSecrets } from '@maka/core/redaction';
import {
createProxiedFetchTransport,
type ProxiedFetchProxy,
type ProxiedFetchTransport,
} from '@maka/runtime/network/scoped-fetch-transport';
import type { RuntimePolicyOperationCoordinator } from '@maka/storage/runtime-policy-stores';
import { toRuntimePolicyProxy } from './runtime-policy-proxy.js';
const MODELS_DEV_FETCH_TIMEOUT_MS = 10_000;
/** A normal refresh retires hundreds of paths; the count is the signal, the names are a sample. */
const LOGGED_REMOVAL_SAMPLE = 20;
export interface HostModelMetadataRefreshInput {
readonly policy: Pick<RuntimePolicyOperationCoordinator, 'resolveHostOutboundExecution'>;
/** Announce the swap so attached clients re-read the connection catalog. */
readonly publish: () => void;
readonly createFetchTransport?: (proxy: ProxiedFetchProxy | null) => ProxiedFetchTransport;
readonly timeoutMs?: number;
}
export interface HostModelMetadataRefresh {
/** Resolves when the one refresh attempt has finished, however it ended. */
readonly settled: Promise<void>;
close(): Promise<void>;
}
/**
* Fetch the models.dev catalog once and make it this Host's model metadata.
*
* The Host is the only process that does this. Clients read Host-resolved
* catalog entries, so a Host on a stale build still describes every model the
* way the live catalog does.
*
* Every failure — offline, timeout, an oversized body, an upstream shape the
* projection refuses — keeps the snapshot compiled into this build. There is
* no partial install: a catalog that does not project whole is not a catalog.
*
* A refresh that lands is taken whole, including what upstream stopped
* carrying: the snapshot is not a second opinion about a model upstream still
* publishes. Where the generator refuses a shrinking refresh until a human
* acknowledges it, the Host records the removals and adopts them — nothing
* here is committed or redistributed, and the next process start asks again.
*
* The attempt is made once, at startup. A Host started in privacy mode does
* not refresh at all, and leaving privacy mode later does not start one.
*/
export function startHostModelMetadataRefresh(
input: HostModelMetadataRefreshInput,
): HostModelMetadataRefresh {
const abort = new AbortController();
const settled = run(input, abort.signal).catch((error: unknown) => {
if (abort.signal.aborted) return;
// The message itself, not a generalized category: the projection names the
// provider and model it refused, and that is the whole diagnostic here.
console.error(
`[runtime-host] models.dev catalog refresh failed, keeping the bundled snapshot: ${redactSecrets(error instanceof Error ? error.message : String(error))}`,
);
});
return {
settled,
close: async () => {
abort.abort(new Error('Runtime Host model metadata refresh closed'));
await settled;
},
};
}
function removalSummary(paths: readonly string[]): string {
const sample = paths.slice(0, LOGGED_REMOVAL_SAMPLE).join(', ');
const rest = paths.length - LOGGED_REMOVAL_SAMPLE;
return `models.dev no longer carries ${paths.length} path(s) the bundled snapshot described; adopting upstream: ${sample}${rest > 0 ? ` and ${rest} more` : ''}`;
}
async function run(input: HostModelMetadataRefreshInput, signal: AbortSignal): Promise<void> {
const admission = await input.policy.resolveHostOutboundExecution();
if (admission.kind !== 'ready') {
console.error(
admission.kind === 'privacy_mode'
? '[runtime-host] models.dev catalog refresh skipped: privacy mode is active'
: '[runtime-host] models.dev catalog refresh skipped: the network proxy credential is not configured',
);
return;
}
signal.throwIfAborted();
const transport = (input.createFetchTransport ?? createProxiedFetchTransport)(
toRuntimePolicyProxy(admission.networkProxy, admission.secretMaterial.networkProxy?.secret),
);
const timeout = AbortSignal.timeout(input.timeoutMs ?? MODELS_DEV_FETCH_TIMEOUT_MS);
try {
const metadata = await fetchModelsDevProjection({
fetch: transport.fetch,
signal: AbortSignal.any([signal, timeout]),
previous: bundledModelMetadata,
onRemovals: (paths) => console.error(`[runtime-host] ${removalSummary(paths)}`),
});
signal.throwIfAborted();
// Install before publishing: a client that re-reads on the frame must find
// the refreshed catalog, not the one it already had.
installRefreshedModelMetadata(metadata);
input.publish();
} finally {
await transport.close();
}
}