Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import type { OpenClawConfigurationDocument } from "@openclaw-enterprise/contracts";
import { asRecord, splitModelRef } from "@openclaw-enterprise/utils";

import type { CodexModelEndpoint } from "./plugin-runtime.ts";

/** Compile the admitted selection into stock OpenClaw's explicit native-provider syntax. */
export function codexGatewayModelConfiguration(
configuration: OpenClawConfigurationDocument,
endpoint: CodexModelEndpoint,
): OpenClawConfigurationDocument {
const document = structuredClone(configuration);
const agents = asRecord(document.agents);
const defaults = asRecord(agents?.defaults);
const entries = Object.values(asRecord(agents?.entries) ?? {}).map(asRecord);
const primary =
typeof defaults?.model === "string" ? defaults.model : asRecord(defaults?.model)?.primary;
const entryPrimary = entries
.map((entry) =>
typeof entry?.model === "string" ? entry.model : asRecord(entry?.model)?.primary,
)
.find((value) => typeof value === "string");
const selected = typeof primary === "string" ? primary : entryPrimary;
if (typeof selected !== "string") {
throw new Error("Dedicated Codex model selection is missing.");
}
const sourceProvider = splitModelRef(selected).provider;
const references = new Map<string, string>();
const selections = new Set<string>();
const qualify = (reference: string): string => {
const { provider, id } = splitModelRef(reference);
if (provider !== sourceProvider) {
return reference;
}
if (id === undefined) {
throw new Error("Dedicated Codex model reference must include its provider.");
}
references.set(reference, id);
return `codex/${endpoint.modelProvider}/${id}`;
};
const select = (reference: string): string => {
// Subagent aliases remain native aliases, even when named like the source provider.
if (!reference.includes("/")) {
return reference;
}
selections.add(reference);
return qualify(reference);
};
for (const settings of [defaults, ...entries]) {
if (settings === undefined) {
continue;
}
for (const scope of [settings, asRecord(settings.subagents)]) {
if (scope === undefined) {
continue;
}
if (typeof scope.model === "string") {
scope.model = select(scope.model);
} else {
const selection = asRecord(scope.model);
if (typeof selection?.primary === "string") {
selection.primary = select(selection.primary);
}
if (Array.isArray(selection?.fallbacks)) {
selection.fallbacks = selection.fallbacks.map((reference) => select(reference));
}
}
}
const policies = asRecord(settings.models);
if (policies !== undefined) {
settings.models = Object.fromEntries(
Object.entries(policies).map(([reference, policy]) => [qualify(reference), policy]),
);
}
}
const models = asRecord(document.models) ?? {};
const providers = asRecord(models.providers) ?? {};
const source = asRecord(providers[sourceProvider]) ?? {};
const catalog = Array.isArray(source.models) ? source.models.map(asRecord) : [];
// Match each declared ref, not a prefix heuristic: a native ID may itself
// start with "codex/", while a catalog may also contain full OCE refs.
const projected = new Map<string, Record<string, unknown>>();
for (const [reference, id] of references) {
const row = catalog.find((entry) => entry?.id === reference || entry?.id === id);
// Policy selectors can name wildcards or discovery hints, not catalog rows.
if (row === undefined && !selections.has(reference)) {
continue;
}
projected.set(id, {
...(row ?? { name: id }),
id: `${endpoint.modelProvider}/${id}`,
});
}
if (sourceProvider !== "codex") {
delete providers[sourceProvider];
}
providers.codex = { ...source, models: [...projected.values()] };
models.providers = providers;
return { ...document, models: models as OpenClawConfigurationDocument };
}
54 changes: 50 additions & 4 deletions apps/controller/src/drivers/compute/kubernetes/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { normalizeOpenAiBaseUrl } from "../../openai-endpoint.ts";
import { compareResourceQuantities } from "./resource-quantities.ts";
import {
asRecord,
Expand Down Expand Up @@ -148,6 +149,7 @@ import {
usesGatewayPasswordEnvironment,
} from "./configuration-rules.ts";
import { nodeProgramArguments } from "../node-program.ts";
import { codexGatewayModelConfiguration } from "../codex-model-configuration.ts";
import { discoverHarnessModels } from "../model-discovery.ts";
import { pollHarnessDeviceAuthorization, startHarnessDeviceAuthorization } from "../device-auth.ts";
import {
Expand Down Expand Up @@ -491,6 +493,8 @@ export interface KubernetesComputeDriverOptions {
readonly nodeSelector?: Readonly<Record<string, string>>;
readonly gatewayNodeSelector?: Readonly<Record<string, string>>;
readonly codexSeccompProfile?: string;
/** HTTPS OpenAI-compatible Responses endpoint for dedicated Codex API-key auth. */
readonly codexModelBaseUrl?: string;
readonly channels?: {
readonly proxyUrl: string;
readonly managedProxy?: KubernetesWorkloadPeer & {
Expand Down Expand Up @@ -2448,8 +2452,12 @@ function requireNativeWorkerSandbox(
function gatewayConfigurationDocument(
revision: AgentRevision,
nativeRuntime: NativeRuntimeSnapshot | undefined,
pluginRuntime?: PluginRuntimeSpec,
): OpenClawConfigurationDocument {
if (nativeRuntime === undefined) {
if (pluginRuntime?.kind === "codex" && pluginRuntime.modelEndpoint !== undefined) {
return codexGatewayModelConfiguration(revision.configuration, pluginRuntime.modelEndpoint);
}
return revision.configuration;
}
const cloudWorkers = asRecord(revision.configuration.cloudWorkers) ?? {};
Expand Down Expand Up @@ -2643,6 +2651,7 @@ export class KubernetesComputeDriver implements ComputeDriver {
nodeSelector: { type: "object", additionalProperties: { type: "string" } },
gatewayNodeSelector: { type: "object", additionalProperties: { type: "string" } },
codexSeccompProfile: { type: "string", minLength: 1 },
codexModelBaseUrl: { type: "string", minLength: 1 },
channels: {
type: "object",
required: ["proxyUrl"],
Expand Down Expand Up @@ -2923,6 +2932,14 @@ export class KubernetesComputeDriver implements ComputeDriver {
"Native OpenClaw session capacity must be an integer between 1 and 1024.",
);
}
if (
options.runtime.codexModelBaseUrl !== undefined &&
normalizeOpenAiBaseUrl(options.runtime.codexModelBaseUrl) === undefined
) {
throw new ConfigurationFailure(
"Codex model endpoint requires HTTPS, a nonzero port, and a path ending in /v1 without credentials, wildcards, query, or fragment.",
);
}
if (options.runtime.codexSeccompProfile !== undefined) {
validateCodexSeccompProfile(options.runtime.codexSeccompProfile);
}
Expand Down Expand Up @@ -5011,9 +5028,6 @@ export class KubernetesComputeDriver implements ComputeDriver {
await this.prepareRevisionStage("workspace_setup", () =>
this.deliverWorkspaceSetup(revision, workspaceSetup, namespace),
);
const nativeDocument = gatewayConfigurationDocument(admittedRevision, nativeRuntime);
const document = JSON.stringify(nativeDocument);
const earlierDocument = gatewayDocumentBeforeLanBind(nativeConfiguration, nativeDocument);
const configuration = await this.prepareRevisionStage("gateway_configuration", async () =>
this.gatewayConfiguration(
admittedRevision,
Expand Down Expand Up @@ -5110,10 +5124,18 @@ export class KubernetesComputeDriver implements ComputeDriver {
repositoryConsumer,
repositoryMaterial,
),
harnessAuth.credentialSource?.config.base_url,
);
const hasEnabledPluginSelections =
pluginRuntime !== undefined &&
Object.values(pluginRuntime.runtime.selections).some((selection) => selection.enabled);
const nativeDocument = gatewayConfigurationDocument(
admittedRevision,
nativeRuntime,
pluginRuntime?.runtime,
);
const document = JSON.stringify(nativeDocument);
const earlierDocument = gatewayDocumentBeforeLanBind(nativeConfiguration, nativeDocument);
const pluginStatusContainer =
sandboxDriver?.provisionHarness === undefined &&
pluginRuntime !== undefined &&
Expand Down Expand Up @@ -5921,6 +5943,7 @@ export class KubernetesComputeDriver implements ComputeDriver {
repositoryConsumer,
repositoryMaterial,
),
harnessAuth.credentialSource?.config.base_url,
);
if (
currentRevisionId !== revision.id ||
Expand Down Expand Up @@ -6023,6 +6046,7 @@ export class KubernetesComputeDriver implements ComputeDriver {
repositoryConsumer,
repositoryMaterial,
),
harnessAuth.credentialSource?.config.base_url,
);
const revisionName = `${agentName}-rev-${sha256Hex(revision.id, 12)}`;
const configuration = this.gatewayConfiguration(
Expand Down Expand Up @@ -11454,10 +11478,32 @@ chmodSync(${JSON.stringify(nodeStatePath)}, 0o700);`;
private pluginRuntimeSnapshot(
revision: AgentRevision,
repositoryBrokerNetworkPolicy?: CodexRepositoryBrokerNetworkPolicy,
openaiBaseUrl?: string,
): PluginRuntimeSnapshot | undefined {
if (
openaiBaseUrl === undefined &&
revision.harness.id === "codex" &&
revision.harness.mode === "dedicated" &&
revision.harnessAuth?.method === "api_key"
) {
openaiBaseUrl = this.options.runtime?.codexModelBaseUrl;
}
if (openaiBaseUrl !== undefined) {
const normalized = normalizeOpenAiBaseUrl(openaiBaseUrl);
if (normalized === undefined) {
throw new ConfigurationFailure(
"Codex model endpoint requires a valid HTTPS /v1 URL with a nonzero port and no hostname wildcards.",
);
}
openaiBaseUrl = normalized;
}
let runtime: PluginRuntimeSpec | undefined;
try {
runtime = pluginRuntimeSpecForRevision(revision, repositoryBrokerNetworkPolicy);
runtime = pluginRuntimeSpecForRevision(
revision,
repositoryBrokerNetworkPolicy,
openaiBaseUrl,
);
} catch (error) {
throw new ConfigurationFailure(
error instanceof Error
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3536,6 +3536,19 @@ function probeCodexAuthentication(timeout) {
"-c", 'web_search="disabled"',
"-c", "project_doc_max_bytes=0",
"-c", "check_for_update_on_startup=false",
// The authentication probe ignores user config, so carry the admitted
// compatible endpoint explicitly and use its HTTPS Responses transport.
...(pluginRuntime?.manifest.modelEndpoint === undefined ? [] : [
"-c", "model_provider=" + JSON.stringify(pluginRuntime.manifest.modelEndpoint.modelProvider),
"-c", "model_providers." + pluginRuntime.manifest.modelEndpoint.modelProvider + '.name="OpenAI-compatible"',
"-c", "model_providers." + pluginRuntime.manifest.modelEndpoint.modelProvider + ".base_url=" + JSON.stringify(pluginRuntime.manifest.modelEndpoint.baseUrl),
"-c", "model_providers." + pluginRuntime.manifest.modelEndpoint.modelProvider + '.wire_api="responses"',
"-c", "model_providers." + pluginRuntime.manifest.modelEndpoint.modelProvider + ".requires_openai_auth=true",
"-c", "model_providers." + pluginRuntime.manifest.modelEndpoint.modelProvider + ".supports_websockets=false",
]),
...(loginMode === "chatgpt_service_account" ? [
"-c", "forced_chatgpt_workspace_id=" + JSON.stringify(workspaceId),
] : []),
"Reply only READY. Do not use tools.",
], {
cwd: directory,
Expand Down
30 changes: 26 additions & 4 deletions apps/controller/src/drivers/compute/plugin-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ export interface CodexRepositoryBrokerNetworkPolicy {
readonly domains: Readonly<Record<string, "allow" | "deny">>;
}

export interface CodexModelEndpoint {
readonly baseUrl: string;
readonly modelProvider: "openai-compatible";
}

export type PluginRuntimeSpec =
| {
readonly kind: "openclaw";
Expand All @@ -54,6 +59,7 @@ export type PluginRuntimeSpec =
readonly approvalPolicy?: string | undefined;
readonly pluginApprovers?: AgentRevision["pluginApprovers"];
readonly repositoryBrokerNetworkPolicy?: CodexRepositoryBrokerNetworkPolicy;
readonly modelEndpoint?: CodexModelEndpoint;
};

function validateDriverMatchesRuntime(
Expand Down Expand Up @@ -88,6 +94,7 @@ function validateDriverMatchesRuntime(
function pluginFreeRuntimeForRevision(
revision: Readonly<AgentRevision>,
repositoryBrokerNetworkPolicy?: CodexRepositoryBrokerNetworkPolicy,
openaiBaseUrl?: string,
): PluginRuntimeSpec | undefined {
if (!validPluginApprovers(revision.pluginApprovers)) {
throw new Error("AgentRevision plugin approvers are invalid.");
Expand All @@ -100,6 +107,9 @@ function pluginFreeRuntimeForRevision(
...(revision.pluginApprovers === undefined
? {}
: { pluginApprovers: revision.pluginApprovers }),
...(openaiBaseUrl === undefined
? {}
: { modelEndpoint: { baseUrl: openaiBaseUrl, modelProvider: "openai-compatible" } }),
...(repositoryBrokerNetworkPolicy === undefined ? {} : { repositoryBrokerNetworkPolicy }),
};
}
Expand All @@ -119,10 +129,11 @@ function pluginFreeRuntimeForRevision(
export function pluginRuntimeSpecForRevision(
revision: Readonly<AgentRevision>,
repositoryBrokerNetworkPolicy?: CodexRepositoryBrokerNetworkPolicy,
openaiBaseUrl?: string,
): PluginRuntimeSpec | undefined {
const state = revision.plugins;
if (state === undefined) {
return pluginFreeRuntimeForRevision(revision, repositoryBrokerNetworkPolicy);
return pluginFreeRuntimeForRevision(revision, repositoryBrokerNetworkPolicy, openaiBaseUrl);
}
if (!validPluginRevisionState(state) || !validPluginApprovers(revision.pluginApprovers)) {
throw new Error("AgentRevision plugin selections are invalid.");
Expand All @@ -140,6 +151,9 @@ export function pluginRuntimeSpecForRevision(
selections: state.plugins,
approvalPolicy: codexSessionApprovalPolicy(revision),
pluginApprovers: revision.pluginApprovers,
...(openaiBaseUrl === undefined
? {}
: { modelEndpoint: { baseUrl: openaiBaseUrl, modelProvider: "openai-compatible" } }),
...(repositoryBrokerNetworkPolicy === undefined ? {} : { repositoryBrokerNetworkPolicy }),
}
: { kind: "openclaw", selections: state.plugins, pluginApprovers: revision.pluginApprovers };
Expand All @@ -165,9 +179,14 @@ function codexToml(
? CODEX_NO_PLUGIN_FEATURES_TOML
: CODEX_SELECTED_PLUGIN_FEATURES_TOML
}${pluginDefaults ? CODEX_PLUGIN_DEFAULTS_TOML : ""}`;
return approvalPolicy === undefined
? plugins
: `approval_policy = ${JSON.stringify(approvalPolicy)}\n\n${plugins}`;
const configuration =
approvalPolicy === undefined
? plugins
: `approval_policy = ${JSON.stringify(approvalPolicy)}\n\n${plugins}`;
const endpoint = runtime.modelEndpoint;
return endpoint === undefined
? configuration
: `model_provider = ${JSON.stringify(endpoint.modelProvider)}\n\n${configuration}\n[model_providers.${endpoint.modelProvider}]\nname = "OpenAI-compatible"\nbase_url = ${JSON.stringify(endpoint.baseUrl)}\nwire_api = "responses"\nrequires_openai_auth = true\nsupports_websockets = false\n`;
}

/**
Expand Down Expand Up @@ -248,6 +267,9 @@ function runtimeManifest(runtime: PluginRuntimeSpec): Readonly<Record<string, un
return {
kind: runtime.kind,
selections: runtime.selections,
...(runtime.kind === "codex" && runtime.modelEndpoint !== undefined
? { modelEndpoint: runtime.modelEndpoint }
: {}),
...(runtime.pluginApprovers === undefined ? {} : { pluginApprovers: runtime.pluginApprovers }),
...(runtime.kind === "codex" && runtime.repositoryBrokerNetworkPolicy !== undefined
? { repositoryBrokerNetworkPolicy: runtime.repositoryBrokerNetworkPolicy }
Expand Down
Loading
Loading