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
50 changes: 50 additions & 0 deletions packages/agent/src/gateway-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
isBlockedModelId,
isCloudflareModel,
pickAllowedModel,
resetGatewayModelCaches,
} from "./gateway-models";

const model = (id: string, owned_by = ""): GatewayModel => ({
Expand Down Expand Up @@ -240,6 +241,55 @@ describe("gateway models cache", () => {
expect(cached[0]?.allowed).toBe(false);
});

// The stale-catalog bug: a model can be marked restricted (or dropped) for a
// token, then become available for that same token after an entitlement
// change. resetGatewayModelCaches() must let the next fetch re-read the
// gateway so the model reappears without a token change (i.e. without a full
// sign-out/sign-in — the workaround affected users had to use).
const codexResponse = (allowed: boolean) =>
new Response(
JSON.stringify({
object: "list",
data: [
{
id: "gpt-5.5",
owned_by: "openai",
context_window: 400000,
supports_streaming: true,
supports_vision: false,
allowed,
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);

it.each([
{ name: "fetchGatewayModels", fn: fetchGatewayModels },
{ name: "fetchModelsList", fn: fetchModelsList },
])(
"$name refreshes the catalog for the same token after a reset",
async ({ fn }) => {
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(codexResponse(false))
.mockResolvedValueOnce(codexResponse(true));
const gatewayUrl = "https://gateway.reset-refresh-test";

const before = await fn({ gatewayUrl, authToken: "tok-a" });
// Same token would otherwise be served the stale cached list.
const stillCached = await fn({ gatewayUrl, authToken: "tok-a" });

resetGatewayModelCaches();
const afterReset = await fn({ gatewayUrl, authToken: "tok-a" });

expect(fetchMock).toHaveBeenCalledTimes(2);
expect(before[0]?.allowed).toBe(false);
expect(stillCached[0]?.allowed).toBe(false);
expect(afterReset[0]?.allowed).toBe(true);
},
);

it("corrects stale GLM 5.2 context-window metadata", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
Expand Down
15 changes: 15 additions & 0 deletions packages/agent/src/gateway-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,21 @@ export async function fetchModelsList(
}
}

/**
* Drop the in-process model caches so the next fetch re-reads the gateway.
*
* The caches are otherwise only busted by TTL expiry or a change of the
* `(url, token)` key, so an entitlement change that keeps the same token — a
* plan upgrade, an org switch that reuses the access token, a model rollout —
* would keep serving a stale catalog until the TTL lapsed. Wiring this to
* auth-state changes (see AgentService) lets a model that becomes available
* surface without waiting out the TTL or forcing a full sign-out/sign-in.
*/
export function resetGatewayModelCaches(): void {
gatewayModelsCache = null;
modelsListCache = null;
}

/**
* The model a session should start on: the preferred id when present and
* allowed, else the newest allowed model — a free-tier org must not default
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,16 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
getState(): AuthState {
return { ...this.state };
}
/**
* Subscribe to auth-state changes; returns an unsubscribe. A thin wrapper
* over the `StateChanged` event for consumers that only need "something
* changed" (e.g. dropping identity-scoped caches) without depending on the
* event key.
*/
onAuthStateChanged(listener: () => void): () => void {
this.on(AuthServiceEvent.StateChanged, listener);
return () => this.off(AuthServiceEvent.StateChanged, listener);
}
async login(region: CloudRegion): Promise<AuthState> {
await this.authenticateWithFlow(
() => this.oauthFlow.startFlow(region),
Expand Down
1 change: 1 addition & 0 deletions packages/workspace-server/src/services/agent/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ function createMockDependencies() {
getPluginPath: vi.fn(() => "/mock/plugin"),
},
agentAuthAdapter: {
onAuthStateChanged: vi.fn(() => () => {}),
ensureGatewayProxy: vi.fn().mockResolvedValue("http://127.0.0.1:9999"),
configureProcessEnv: vi.fn().mockResolvedValue(undefined),
createPosthogConfig: vi.fn((credentials) => ({
Expand Down
9 changes: 9 additions & 0 deletions packages/workspace-server/src/services/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
isCloudflareModel,
isOpenAIModel,
pickAllowedModel,
resetGatewayModelCaches,
} from "@posthog/agent/gateway-models";
import { getLlmGatewayUrl } from "@posthog/agent/posthog-api";
import {
Expand Down Expand Up @@ -441,6 +442,14 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
this.onAgentLog = makeOnAgentLog(loggerFactory);

powerManager.onResume(() => this.checkIdleDeadlines());

// A stale model catalog must not outlive an entitlement change. Dropping
// the cache on every auth-state change means the next picker/config read
// re-reads the gateway, so a model that becomes available (or an org
// switch) surfaces without a full sign-out/sign-in. The refetch is cheap
// and still TTL-cached, and this service is a process-lifetime singleton so
// the subscription never needs tearing down.
this.agentAuthAdapter.onAuthStateChanged(() => resetGatewayModelCaches());
}

private getClaudeCliPath(): string {
Expand Down
5 changes: 5 additions & 0 deletions packages/workspace-server/src/services/agent/auth-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,11 @@ export class AgentAuthAdapter {
}
}

/** Subscribe to auth-state changes; returns an unsubscribe. */
onAuthStateChanged(listener: () => void): () => void {
return this.authService.onAuthStateChanged(listener);
}

async configureProcessEnv({
credentials,
proxyUrl,
Expand Down
2 changes: 2 additions & 0 deletions packages/workspace-server/src/services/agent/ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,6 @@ export interface AgentAuth {
input: string | Request,
init?: RequestInit,
): Promise<Response>;
/** Subscribe to auth-state changes; returns an unsubscribe. */
onAuthStateChanged(listener: () => void): () => void;
}
Loading