Skip to content
Closed
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
8 changes: 8 additions & 0 deletions packages/agent/src/adapters/acp-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ export type AcpConnectionConfig = {
processCallbacks?: ProcessSpawnedCallback;
codexOptions?: CodexOptions;
allowedModelIds?: Set<string>;
/**
* Models outside the org's plan (the gateway's `allowed: false` marks).
* Pickers render these locked behind an upgrade gate instead of omitting
* them. Codex-only: the Claude adapter reads marks off its own authed
* models fetch, while codex's model/list round-trip drops unknown fields.
*/
restrictedModelIds?: Set<string>;
/** Callback invoked when the agent calls the create_output tool for structured output */
onStructuredOutput?: (output: Record<string, unknown>) => Promise<void>;
/** PostHog API config; when set, enables file-read enrichment unless disabled. */
Expand Down Expand Up @@ -230,6 +237,7 @@ function createCodexConnection(config: AcpConnectionConfig): AcpConnection {
},
model: codexOptions.model,
reasoningEffort: codexOptions.reasoningEffort,
restrictedModelIds: config.restrictedModelIds,
processCallbacks: config.processCallbacks,
onStructuredOutput: config.onStructuredOutput,
logger: config.logger?.child("CodexAppServerAgent"),
Expand Down
28 changes: 25 additions & 3 deletions packages/agent/src/adapters/base-acp-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
WriteTextFileRequest,
WriteTextFileResponse,
} from "@agentclientprotocol/sdk";
import { restrictedModelMeta } from "@posthog/shared";
import {
DEFAULT_GATEWAY_MODEL,
fetchGatewayModels,
Expand All @@ -25,6 +26,7 @@ import {
isAnthropicModel,
isCloudflareModel,
isCloudflareModelId,
pickAllowedModel,
} from "../gateway-models";
import { Logger } from "../utils/logger";
/**
Expand Down Expand Up @@ -136,22 +138,30 @@ export abstract class BaseAcpAgent implements Agent {
async getModelConfigOptions(
currentModelOverride?: string,
gatewayUrl?: string,
gatewayAuthToken?: string,
): Promise<{
currentModelId: string;
options: SessionConfigSelectOption[];
}> {
// Authenticated so the gateway can mark models outside the org's plan
// (`allowed: false`) — anonymous fetches see everything allowed.
this.gatewayModels = await fetchGatewayModels(
gatewayUrl ? { gatewayUrl } : undefined,
gatewayUrl ? { gatewayUrl, authToken: gatewayAuthToken } : undefined,
);

const options = this.gatewayModels
const adapterModels = this.gatewayModels
// Cloudflare models are servable on the Claude adapter too — the gateway translates the
// `@cf/` path onto its Anthropic-Messages surface — so include them alongside Anthropic models.
.filter((model) => isAnthropicModel(model) || isCloudflareModel(model))
.filter((model) => isAnthropicModel(model) || isCloudflareModel(model));

const options = adapterModels
.map((model) => ({
value: model.id,
name: formatGatewayModelName(model),
description: `Context: ${model.context_window.toLocaleString()} tokens`,
// Free-tier gate: locked models stay listed so the picker can render
// them behind an upgrade gate instead of silently vanishing.
...(model.allowed ? {} : { _meta: restrictedModelMeta() }),
}))
// Sort oldest-to-newest so the picker is deterministic and the newest
// model lands at the end of the list, closest to the trigger.
Expand Down Expand Up @@ -186,6 +196,18 @@ export abstract class BaseAcpAgent implements Agent {
}
}

// Never auto-select a model the org's plan can't use — a free-tier org
// would 403 on its first message. An explicit user pick still goes
// through the picker, which gates locked models behind the upgrade flow.
const startedOn = currentModelId;
currentModelId = pickAllowedModel(adapterModels, currentModelId);
if (currentModelId !== startedOn) {
this.logger.info(
"Requested model is outside the org's plan; starting on an allowed model",
{ requestedModel: startedOn, selectedModel: currentModelId },
);
}

if (!options.some((opt) => opt.value === currentModelId)) {
options.unshift({
value: currentModelId,
Expand Down
1 change: 1 addition & 0 deletions packages/agent/src/adapters/claude/claude-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2097,6 +2097,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
this.getModelConfigOptions(
settingsManager.getSettings().model || meta?.model || undefined,
this.options?.gatewayEnv?.anthropicBaseUrl,
this.options?.gatewayEnv?.anthropicAuthToken,
),
...(meta?.taskRunId
? [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ export interface CodexAppServerAgentOptions {
processOptions: CodexAppServerProcessOptions;
model?: string;
reasoningEffort?: string;
/** Models outside the org's plan; the picker renders them locked. */
restrictedModelIds?: ReadonlySet<string>;
processCallbacks?: ProcessSpawnedCallback;
logger?: Logger;
onStructuredOutput?: (output: Record<string, unknown>) => Promise<void>;
Expand Down Expand Up @@ -236,6 +238,7 @@ export class CodexAppServerAgent extends BaseAcpAgent {
this.config = new SessionConfigState(
options.model ?? DEFAULT_CODEX_MODEL,
options.reasoningEffort,
options.restrictedModelIds,
);
this.onStructuredOutput = options.onStructuredOutput;
this.developerInstructions = options.processOptions.developerInstructions;
Expand Down
24 changes: 22 additions & 2 deletions packages/agent/src/adapters/codex-app-server/session-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type CodexModePreset,
type ExecutionMode,
resolveCloudInitialPermissionMode,
restrictedModelMeta,
} from "@posthog/shared";
import { type GatewayModel, isOpenAIModel } from "../../gateway-models";
import { getReasoningEffortOptions } from "./models";
Expand Down Expand Up @@ -160,6 +161,12 @@ export interface ConfigSelectors {
/** From model/list; falls back to the single current model when empty. */
models: Array<{ id: string; name: string }>;
efforts: string[];
/**
* Models outside the org's plan (free-tier model gate). Rendered locked
* behind an upgrade gate by the picker. Sourced from the agent's own authed
* gateway models fetch — codex's model/list round-trip drops the marks.
*/
restrictedModelIds?: ReadonlySet<string>;
}

/** Builds the ACP configOptions (mode + model + thought_level) the host renders. */
Expand Down Expand Up @@ -195,7 +202,13 @@ export function buildConfigOptions(s: ConfigSelectors): SessionConfigOption[] {
name: "Model",
category: "model",
currentValue: s.model,
options: models.map((m) => ({ name: m.name, value: m.id })),
options: models.map((m) => ({
name: m.name,
value: m.id,
...(s.restrictedModelIds?.has(m.id)
? { _meta: restrictedModelMeta() }
: {}),
})),
} as unknown as SessionConfigOption,
{
type: "select",
Expand Down Expand Up @@ -229,10 +242,16 @@ export class SessionConfigState {
private models: Array<{ id: string; name: string }> = [];
private efforts: string[] = [];
private _options: SessionConfigOption[] = [];
private readonly restrictedModelIds?: ReadonlySet<string>;

constructor(model: string, effort?: string) {
constructor(
model: string,
effort?: string,
restrictedModelIds?: ReadonlySet<string>,
) {
this._model = model;
this._effort = effort;
this.restrictedModelIds = restrictedModelIds;
this.rebuild();
}

Expand Down Expand Up @@ -331,6 +350,7 @@ export class SessionConfigState {
effort: this._effort,
models: this.models,
efforts: this.efforts,
restrictedModelIds: this.restrictedModelIds,
});
}
}
32 changes: 23 additions & 9 deletions packages/agent/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
DEFAULT_GATEWAY_MODEL,
fetchModelsList,
isBlockedModelId,
pickAllowedModel,
} from "./gateway-models";
import { PostHogAPIClient, type TaskRunUpdate } from "./posthog-api";
import { SessionLogWriter } from "./session-log-writer";
Expand Down Expand Up @@ -79,33 +80,45 @@ export class Agent {
this.taskRunId = taskRunId;

let allowedModelIds: Set<string> | undefined;
let restrictedModelIds: Set<string> | undefined;
let sanitizedModel =
options.model && !isBlockedModelId(options.model)
? options.model
: undefined;
if (options.adapter === "codex" && gatewayConfig) {
// Authenticated so the gateway can mark models outside the org's plan
// (`allowed: false`) — anonymous fetches see everything allowed.
const models = await fetchModelsList({
gatewayUrl: gatewayConfig.gatewayUrl,
authToken: gatewayConfig.apiKey,
});
const codexModelIds = models
.filter((model) => {
if (isBlockedModelId(model.id)) return false;
if (model.owned_by) {
return model.owned_by === "openai";
}
return model.id.startsWith("gpt-") || model.id.startsWith("openai/");
})
.map((model) => model.id);
const codexModels = models.filter((model) => {
if (isBlockedModelId(model.id)) return false;
if (model.owned_by) {
return model.owned_by === "openai";
}
return model.id.startsWith("gpt-") || model.id.startsWith("openai/");
});
const codexModelIds = codexModels.map((model) => model.id);

if (codexModelIds.length > 0) {
allowedModelIds = new Set(codexModelIds);
restrictedModelIds = new Set(
codexModels.filter((model) => !model.allowed).map((m) => m.id),
);
}

if (!sanitizedModel || !allowedModelIds?.has(sanitizedModel)) {
sanitizedModel = codexModelIds.includes(DEFAULT_CODEX_MODEL)
? DEFAULT_CODEX_MODEL
: codexModelIds[0];
}
// Never start a session on a model the org's plan can't use — it would
// 403 on the first message. Explicit user picks still flow through the
// picker's upgrade gate.
if (sanitizedModel) {
sanitizedModel = pickAllowedModel(codexModels, sanitizedModel);
}
}
if (!sanitizedModel && options.adapter !== "codex") {
sanitizedModel = DEFAULT_GATEWAY_MODEL;
Expand All @@ -131,6 +144,7 @@ export class Agent {
processCallbacks: options.processCallbacks,
onStructuredOutput: options.onStructuredOutput,
allowedModelIds,
restrictedModelIds,
posthogApiConfig: this.posthogApiConfig,
enricherEnabled: this.enricherEnabled,
claudeGatewayEnv,
Expand Down
5 changes: 5 additions & 0 deletions packages/agent/src/gateway-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const model = (id: string, owned_by = ""): GatewayModel => ({
context_window: 128000,
supports_streaming: true,
supports_vision: false,
allowed: true,
});

describe("formatGatewayModelName", () => {
Expand All @@ -27,6 +28,7 @@ describe("formatGatewayModelName", () => {
context_window: 200000,
supports_streaming: true,
supports_vision: true,
allowed: true,
}),
).toBe("Claude Opus 4.8");
});
Expand All @@ -39,6 +41,7 @@ describe("formatGatewayModelName", () => {
context_window: 200000,
supports_streaming: true,
supports_vision: true,
allowed: true,
}),
).toBe("gpt-5.5");
});
Expand All @@ -51,6 +54,7 @@ describe("formatGatewayModelName", () => {
context_window: 200000,
supports_streaming: true,
supports_vision: true,
allowed: true,
}),
).toBe("gpt-5.5");
});
Expand All @@ -63,6 +67,7 @@ describe("formatGatewayModelName", () => {
context_window: 128000,
supports_streaming: true,
supports_vision: false,
allowed: true,
}),
).toBe("glm-5.2");
});
Expand Down
Loading
Loading